Files
2026-06-26 11:00:48 +03:00

174 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Ban, CheckCircle2, ChevronRight, FileKey2, Loader2 } from 'lucide-react';
import { useAuth } from '@/components/id/auth-provider';
import { IdShell } from '@/components/id/shell';
import { usePublicSettings } from '@/components/id/public-settings-provider';
import { useToast } from '@/components/id/toast-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useRequireAuth } from '@/hooks/use-require-auth';
import {
fetchUserOAuthConsents,
getApiErrorMessage,
revokeOAuthConsent,
type OAuthUserConsent
} from '@/lib/api';
function formatGrantedAt(value: string) {
return new Intl.DateTimeFormat('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value));
}
export default function DataConsentsPage() {
const router = useRouter();
const { user, token } = useAuth();
const { projectName } = usePublicSettings();
const { isReady, isPinLocked } = useRequireAuth();
const { showToast } = useToast();
const [consents, setConsents] = useState<OAuthUserConsent[]>([]);
const [loading, setLoading] = useState(true);
const [selectedConsent, setSelectedConsent] = useState<OAuthUserConsent | null>(null);
const [revoking, setRevoking] = useState(false);
const loadConsents = useCallback(async () => {
if (!user || !token || isPinLocked) return;
setLoading(true);
try {
const response = await fetchUserOAuthConsents(user.id, token);
setConsents(response.consents ?? []);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить доступы') ?? 'Ошибка');
} finally {
setLoading(false);
}
}, [isPinLocked, showToast, token, user]);
useEffect(() => {
if (isReady && user && !isPinLocked) void loadConsents();
}, [isPinLocked, isReady, loadConsents, user]);
async function handleRevoke() {
if (!user || !token || !selectedConsent) return;
setRevoking(true);
try {
await revokeOAuthConsent(user.id, selectedConsent.id, token);
setConsents((current) => current.filter((item) => item.id !== selectedConsent.id));
setSelectedConsent(null);
showToast('Доступ отозван');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отозвать доступ') ?? 'Ошибка');
} finally {
setRevoking(false);
}
}
return (
<IdShell active="/data">
<button
type="button"
onClick={() => router.push('/data')}
className="mb-6 text-sm text-[#3390ec] transition hover:underline"
>
Назад к данным
</button>
<h1 className="text-2xl font-medium">Доступы к данным</h1>
<p className="mt-1 text-sm text-[#667085]">
Настройте, к каким данным аккаунта есть доступ у сервисов. После отзыва при следующем входе доступ нужно будет подтвердить снова.
</p>
<div className="mt-6 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
{loading ? (
<div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загружаем приложения...
</div>
) : consents.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-[#667085]">Вы ещё не выдавали доступ ни одному приложению</p>
) : (
consents.map((consent) => (
<button
key={consent.id}
type="button"
onClick={() => setSelectedConsent(consent)}
className="flex w-full items-center gap-4 border-b border-[#eceef4] px-4 py-4 text-left transition last:border-b-0 hover:bg-[#fafbfd]"
>
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white">
<FileKey2 className="h-5 w-5 text-[#667085]" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium">{consent.clientName}</div>
<div className="truncate text-sm text-[#667085]">Выданы {formatGrantedAt(consent.grantedAt)}</div>
</div>
<ChevronRight className="h-5 w-5 shrink-0 text-[#a8adbc]" />
</button>
))
)}
</div>
<Dialog open={Boolean(selectedConsent)} onOpenChange={(open) => !open && setSelectedConsent(null)}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
{selectedConsent ? (
<>
<DialogHeader>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
<CheckCircle2 className="h-6 w-6" />
</div>
<DialogTitle className="text-center text-xl">{selectedConsent.clientName}</DialogTitle>
</DialogHeader>
<p className="text-center text-sm text-[#667085]">
Выданы {formatGrantedAt(selectedConsent.grantedAt)}
</p>
<div className="mt-4 rounded-2xl bg-[#f4f5f8] p-4">
<p className="text-sm font-medium">API {projectName}</p>
<ul className="mt-3 space-y-2">
{selectedConsent.scopes.map((scope) => (
<li key={scope.slug} className="flex items-start gap-2 text-sm">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[#3390ec]" />
<div>
<div>{scope.description ?? scope.name}</div>
</div>
</li>
))}
</ul>
</div>
<p className="mt-4 text-center text-xs text-[#667085]">ID: {selectedConsent.id.slice(0, 8).toUpperCase()}</p>
<div className="mt-6 space-y-3">
<Button
variant="outline"
className="w-full rounded-xl"
disabled={revoking}
onClick={() => void handleRevoke()}
>
{revoking ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Отзываем...
</>
) : (
<>
<Ban className="mr-2 h-4 w-4" />
Отозвать доступы
</>
)}
</Button>
<Button className="w-full rounded-xl" variant="secondary" onClick={() => setSelectedConsent(null)}>
Закрыть
</Button>
</div>
</>
) : null}
</DialogContent>
</Dialog>
</IdShell>
);
}