Files
IdP/apps/frontend/components/id/oauth-client-detail-dialog.tsx
2026-06-29 14:40:35 +03:00

211 lines
9.4 KiB
TypeScript
Raw 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 { useEffect, useState } from 'react';
import { Copy, ExternalLink, KeyRound, Loader2, RefreshCw, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { OAuthClient } from '@/lib/api';
import { OAuthEndpoints, buildAuthorizeUrl } from '@/lib/oauth-url';
import { OAuthClientOneTapSection } from '@/components/id/oauth-client-one-tap-section';
function CopyRow({ label, value, onCopy }: { label: string; value: string; onCopy: (value: string, label: string) => void }) {
return (
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-medium">{label}</span>
<Button variant="ghost" size="icon" aria-label={`Скопировать ${label}`} onClick={() => onCopy(value, label)}>
<Copy className="h-4 w-4" />
</Button>
</div>
<code className="block break-all text-xs">{value}</code>
</div>
);
}
export function OAuthClientDetailDialog({
client,
endpoints,
frontendBase,
projectName,
open,
onOpenChange,
onCopy,
onRotateSecret,
onToggleActive,
onDelete,
onSaveRedirectUris
}: {
client: OAuthClient | null;
endpoints: OAuthEndpoints;
frontendBase: string;
projectName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onCopy: (value: string, label: string) => void;
onRotateSecret: (clientId: string) => void;
onToggleActive: (client: OAuthClient) => void;
onDelete: (client: OAuthClient) => void;
onSaveRedirectUris: (client: OAuthClient, redirectUris: string[]) => Promise<void>;
}) {
const [redirectUrisDraft, setRedirectUrisDraft] = useState('');
const [savingRedirectUris, setSavingRedirectUris] = useState(false);
useEffect(() => {
if (!client) return;
setRedirectUrisDraft(client.redirectUris.join('\n'));
}, [client]);
if (!client) return null;
const primaryRedirect = client.redirectUris[0] ?? 'https://app.example.com/oauth/callback';
const scope = client.scopes.join(' ');
const sampleAuthorizeUrl = buildAuthorizeUrl(endpoints.issuer, {
clientId: client.clientId,
redirectUri: primaryRedirect,
scope
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{client.name}</DialogTitle>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-[#eef4ff] px-3 py-1 text-xs font-medium text-[#1d4ed8]">
{client.type === 'PUBLIC' ? 'Public' : 'Confidential'}
</span>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${client.isActive ? 'bg-emerald-50 text-emerald-700' : 'bg-zinc-100 text-zinc-600'}`}>
{client.isActive ? 'Активно' : 'Отключено'}
</span>
{client.createdByDisplayName ? (
<span className="rounded-full bg-zinc-100 px-3 py-1 text-xs text-zinc-600">Создал: {client.createdByDisplayName}</span>
) : null}
</div>
<section className="space-y-3">
<h3 className="font-medium">Данные клиента</h3>
<CopyRow label="Client ID" value={client.clientId} onCopy={onCopy} />
{client.type !== 'PUBLIC' ? (
<p className="text-xs text-[#667085]">
Client Secret выдаётся один раз при создании или после «Новый secret». Храните только на backend.
</p>
) : null}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">Redirect URI</span>
{client.canManage ? null : (
<Button variant="ghost" size="icon" aria-label="Скопировать Redirect URI" onClick={() => onCopy(client.redirectUris.join('\n'), 'Redirect URI')}>
<Copy className="h-4 w-4" />
</Button>
)}
</div>
{client.canManage ? (
<>
<textarea
value={redirectUrisDraft}
onChange={(event) => setRedirectUrisDraft(event.target.value)}
placeholder="https://app.example.com/oauth/callback"
className="min-h-[96px] w-full rounded-xl border border-[#eceef4] bg-white px-3 py-2 text-sm"
/>
<p className="text-xs text-[#667085]">
По одному URI на строку. Должен точно совпадать с redirect_uri в запросе authorize (протокол, домен, путь, слэш).
</p>
<Button
size="sm"
disabled={savingRedirectUris}
onClick={() => {
const redirectUris = redirectUrisDraft
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
setSavingRedirectUris(true);
void onSaveRedirectUris(client, redirectUris).finally(() => setSavingRedirectUris(false));
}}
>
{savingRedirectUris ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Сохранить redirect URI
</Button>
</>
) : (
<div className="rounded-2xl bg-[#f4f5f8] p-4 text-sm">
<code className="block whitespace-pre-wrap break-all text-xs">{client.redirectUris.join('\n')}</code>
</div>
)}
</div>
<CopyRow label="Scopes" value={scope} onCopy={onCopy} />
</section>
<section className="space-y-3">
<h3 className="font-medium">Подключение OAuth 2.0 / OpenID Connect</h3>
<p className="text-xs text-[#667085]">
Issuer и endpoints берутся из настроек <strong>PUBLIC_API_URL</strong> и <strong>PUBLIC_FRONTEND_URL</strong> в админке.
</p>
<CopyRow label="Issuer (PUBLIC_API_URL)" value={endpoints.issuer} onCopy={onCopy} />
<CopyRow label="Authorization endpoint" value={endpoints.authorizationEndpoint} onCopy={onCopy} />
<CopyRow label="Token endpoint" value={endpoints.tokenEndpoint} onCopy={onCopy} />
<CopyRow label="UserInfo endpoint" value={endpoints.userInfoEndpoint} onCopy={onCopy} />
<CopyRow label="OIDC Discovery" value={endpoints.openIdConfigurationUrl} onCopy={onCopy} />
<CopyRow label="Пример authorize URL" value={sampleAuthorizeUrl} onCopy={onCopy} />
</section>
<OAuthClientOneTapSection
client={client}
endpoints={endpoints}
frontendBase={frontendBase}
projectName={projectName}
onCopy={onCopy}
/>
<section className="rounded-2xl border border-[#eceef4] p-4">
<div className="mb-2 flex items-center gap-2 font-medium">
<KeyRound className="h-4 w-4" />
Быстрый старт
</div>
<ol className="list-decimal space-y-1 pl-5 text-xs text-[#667085]">
<li>Укажите issuer = PUBLIC_API_URL в OIDC-клиенте (PHP, Keycloak, Grafana и т.д.).</li>
<li>Перенаправьте пользователя на authorization endpoint стандартные параметры client_id, redirect_uri, response_type=code.</li>
<li>IdP покажет вход и экран «Разрешить доступ», затем вернёт code на redirect_uri.</li>
<li>На backend обменяйте code через POST /oauth/token (form-urlencoded или JSON).</li>
</ol>
<a
href={endpoints.openIdConfigurationUrl}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-xs text-[#1d4ed8] hover:underline"
>
Открыть OpenID Configuration
<ExternalLink className="h-3.5 w-3.5" />
</a>
</section>
{client.canManage ? (
<div className="flex flex-wrap gap-2 border-t border-[#eceef4] pt-4">
{client.type !== 'PUBLIC' ? (
<Button variant="secondary" size="sm" onClick={() => onRotateSecret(client.clientId)}>
<RefreshCw className="h-4 w-4" />
Новый secret
</Button>
) : null}
<Button variant="secondary" size="sm" onClick={() => onToggleActive(client)}>
{client.isActive ? 'Отключить' : 'Включить'}
</Button>
<Button
variant="secondary"
size="sm"
className="text-red-600 hover:text-red-700"
onClick={() => onDelete(client)}
>
<Trash2 className="h-4 w-4" />
Удалить
</Button>
</div>
) : null}
</div>
</DialogContent>
</Dialog>
);
}