cebce0ae3b
Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по подтверждённому провайдером email через blind index, вход забаненному на инстансе запрещён, все входы и привязки попадают в события безопасности, привязка — ещё и в аудит инстанса. Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256, токены провайдеров не сохраняются вовсе; state подписывается ключом сессий и ограничен по времени, редирект после входа — только внутренний путь. Если провайдер не настроен, ручка отвечает oauth.provider_not_configured, а клиент показывает человеческий текст вместо кнопки. Клиент: кнопки входа по списку включённых провайдеров на экране входа и раздел «Вход через внешние сервисы» в настройках безопасности; адрес возврата для настроек приложения виден в GET /auth/oauth/providers. Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example знают про OAUTH_* и сохраняют значения при --reconfigure. Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой провайдер), создание и повторный вход, привязка к существующему аккаунту, неподтверждённый email, выключенная регистрация, бан инстанса, mocked провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка возврата; серверная ручка отдаёт понятный отказ без настроек.
48 lines
1.8 KiB
TypeScript
48 lines
1.8 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { fetchOAuthProviders } from '@/api/oauth';
|
|
import { Card } from '@/components/ui/primitives';
|
|
|
|
/**
|
|
* Вход через внешние провайдеры (Фаза 7). Раздел только показывает, какие
|
|
* провайдеры включены на инстансе: привязка происходит при входе по
|
|
* подтверждённому email, а отвязка — действие оператора/администратора.
|
|
*/
|
|
export function LinkedAccountsSection() {
|
|
const { t } = useTranslation();
|
|
const providers = useQuery({
|
|
queryKey: ['auth', 'oauth', 'providers'],
|
|
queryFn: ({ signal }) => fetchOAuthProviders(signal),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
const enabled = providers.data?.providers ?? [];
|
|
|
|
return (
|
|
<Card>
|
|
<h2 className="text-lg font-semibold">{t('settings.security.oauthTitle')}</h2>
|
|
<p className="mt-1 text-sm text-fg-muted">{t('settings.security.oauthDescription')}</p>
|
|
|
|
{enabled.length === 0 ? (
|
|
<p className="mt-3 text-sm text-fg-muted" data-testid="oauth-none">
|
|
{t('settings.security.oauthDisabled')}
|
|
</p>
|
|
) : (
|
|
<ul className="mt-3 flex flex-col gap-2" data-testid="oauth-providers">
|
|
{enabled.map((provider) => (
|
|
<li
|
|
key={provider.id}
|
|
className="flex items-center justify-between rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3 text-sm"
|
|
>
|
|
<span>{provider.name}</span>
|
|
<span className="text-xs text-fg-muted">
|
|
{t('settings.security.oauthLinkedByEmail')}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|