146 lines
6.7 KiB
TypeScript
146 lines
6.7 KiB
TypeScript
'use client';
|
||
|
||
import { Copy, ExternalLink, KeyRound, 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';
|
||
|
||
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,
|
||
open,
|
||
onOpenChange,
|
||
onCopy,
|
||
onRotateSecret,
|
||
onToggleActive,
|
||
onDelete
|
||
}: {
|
||
client: OAuthClient | null;
|
||
endpoints: OAuthEndpoints;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
onCopy: (value: string, label: string) => void;
|
||
onRotateSecret: (clientId: string) => void;
|
||
onToggleActive: (client: OAuthClient) => void;
|
||
onDelete: (client: OAuthClient) => void;
|
||
}) {
|
||
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}
|
||
<CopyRow label="Redirect URI" value={client.redirectUris.join('\n')} onCopy={onCopy} />
|
||
<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> в админке. В стороннем сервисе укажите issuer = базовый URL API, не authorization endpoint.
|
||
</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>
|
||
|
||
<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>
|
||
);
|
||
}
|