add oauth app connected
This commit is contained in:
171
apps/frontend/app/data/consents/page.tsx
Normal file
171
apps/frontend/app/data/consents/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'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 { 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 { 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 Lendry ID</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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Car, ChevronRight, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||
import { Car, ChevronRight, FileKey2, FileText, Loader2, Mail, Phone, Trash2, UserRound } from 'lucide-react';
|
||||
import { AddressQuickSection } from '@/components/addresses/address-quick-section';
|
||||
import { DocumentFormDialog } from '@/components/documents/document-form-dialog';
|
||||
import { ActionTile } from '@/components/id/action-tile';
|
||||
@@ -337,6 +337,14 @@ export default function DataPage() {
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-medium">Управление данными</h2>
|
||||
<div className="mt-2 overflow-hidden rounded-[24px] bg-[#f4f5f8]">
|
||||
<Row
|
||||
icon={FileKey2}
|
||||
title="Доступы к данным"
|
||||
text="Приложения, которым вы разрешили доступ к аккаунту"
|
||||
onClick={() => router.push('/data/consents')}
|
||||
/>
|
||||
</div>
|
||||
{deletionStatus?.pending && deletionStatus.effectiveAt ? (
|
||||
<div className="mt-4 rounded-[24px] border border-amber-200 bg-amber-50/80 p-5">
|
||||
<p className="font-medium text-amber-900">Удаление аккаунта запланировано</p>
|
||||
|
||||
Reference in New Issue
Block a user