mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 11:25:48 +02:00
feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
feat: Two-Factor Authentication (TOTP, Email OTP) and OIDC/SSO – full implementation with admin UI (#933)
This commit is contained in:
@@ -7,23 +7,23 @@ import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setAuthToken, getAuthToken, api } from '../../api/client';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
// Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage)
|
||||
const sessionStorageMock = {
|
||||
store: {} as Record<string, string>,
|
||||
getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
|
||||
getItem: vi.fn((key: string) => sessionStorageMock.store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageMock.store[key] = value;
|
||||
sessionStorageMock.store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageMock.store[key];
|
||||
delete sessionStorageMock.store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
localStorageMock.store = {};
|
||||
sessionStorageMock.store = {};
|
||||
}),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: sessionStorageMock,
|
||||
});
|
||||
|
||||
// Create MSW server
|
||||
@@ -32,22 +32,22 @@ const server = setupServer();
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
localStorageMock.clear();
|
||||
sessionStorageMock.clear();
|
||||
setAuthToken(null);
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe('Auth Token Management', () => {
|
||||
it('setAuthToken stores token in localStorage', () => {
|
||||
it('setAuthToken stores token in sessionStorage', () => {
|
||||
setAuthToken('test-token-123');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
|
||||
expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
|
||||
expect(getAuthToken()).toBe('test-token-123');
|
||||
});
|
||||
|
||||
it('setAuthToken removes token from localStorage when null', () => {
|
||||
it('setAuthToken removes token from sessionStorage when null', () => {
|
||||
setAuthToken('test-token-123');
|
||||
setAuthToken(null);
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(getAuthToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,7 @@ describe('API Client Auth Header', () => {
|
||||
}
|
||||
|
||||
expect(getAuthToken()).toBeNull();
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
});
|
||||
|
||||
it('does not clear token on 401 with generic auth error', async () => {
|
||||
|
||||
@@ -159,4 +159,145 @@ describe('LoginPage', () => {
|
||||
resolveLogin!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('2FA flow', () => {
|
||||
// Helper: login as a 2FA user and get to the 2FA step
|
||||
async function loginWith2FA(twoFAMethods = ['totp', 'backup']) {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
http.post('/api/v1/auth/login', () =>
|
||||
HttpResponse.json({
|
||||
requires_2fa: true,
|
||||
pre_auth_token: 'test-pre-auth-token',
|
||||
two_fa_methods: twoFAMethods,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/Username/i), 'mfa-user');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'mfa-password');
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
it('shows 2FA step when login returns requires_2fa', async () => {
|
||||
await loginWith2FA();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows code input on the 2FA step', async () => {
|
||||
await loginWith2FA();
|
||||
|
||||
await waitFor(() => {
|
||||
// The code input field is rendered
|
||||
expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits 2FA verify request with code and pre_auth_token', async () => {
|
||||
let verifyCalled = false;
|
||||
let verifyBody: unknown;
|
||||
|
||||
server.use(
|
||||
http.post('/api/v1/auth/2fa/verify', async ({ request }) => {
|
||||
verifyCalled = true;
|
||||
verifyBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
access_token: 'final-jwt',
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'mfa-user',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const user = await loginWith2FA();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name: /Verification Code/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
|
||||
await user.click(screen.getByRole('button', { name: /Verify/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(verifyCalled).toBe(true);
|
||||
});
|
||||
|
||||
expect(verifyBody).toMatchObject({
|
||||
pre_auth_token: 'test-pre-auth-token',
|
||||
code: '123456',
|
||||
method: 'totp',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns to credentials step when back button is clicked', async () => {
|
||||
await loginWith2FA();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
const backButton = screen.getByRole('button', { name: /Back to login/i });
|
||||
await user.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows method selector when multiple 2FA methods are available', async () => {
|
||||
await loginWith2FA(['totp', 'email', 'backup']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Multiple method buttons should be visible
|
||||
expect(screen.getByRole('button', { name: /Authenticator/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Email/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Backup/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show method selector with only one 2FA method', async () => {
|
||||
await loginWith2FA(['totp']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Single-method: no method selector buttons
|
||||
expect(screen.queryByRole('button', { name: /Authenticator/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows send code button when email method is selected', async () => {
|
||||
const _user = await loginWith2FA(['email']);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// For email method the "Send code" button should be shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Send Code/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+173
-6
@@ -3,13 +3,21 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// Auth token storage
|
||||
let authToken: string | null = localStorage.getItem('auth_token');
|
||||
// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
|
||||
// When the token originates from the ?token= URL param (kiosk bootstrap), it is
|
||||
// additionally persisted in localStorage so the kiosk survives page reloads.
|
||||
let authToken: string | null =
|
||||
sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
export function setAuthToken(token: string | null, persist = false) {
|
||||
authToken = token;
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
sessionStorage.setItem('auth_token', token);
|
||||
if (persist) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
} else {
|
||||
sessionStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
}
|
||||
@@ -66,6 +74,7 @@ async function request<T>(
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
cache: 'no-store', // Prevent browser caching of API responses
|
||||
credentials: 'include', // Required for HttpOnly cookies (e.g. 2fa_challenge)
|
||||
headers,
|
||||
});
|
||||
|
||||
@@ -2346,9 +2355,13 @@ export interface LoginRequest {
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: UserResponse;
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
user?: UserResponse;
|
||||
/** Set when 2FA verification is required before a full token is issued. */
|
||||
requires_2fa?: boolean;
|
||||
pre_auth_token?: string;
|
||||
two_fa_methods?: string[];
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
@@ -2414,6 +2427,66 @@ export interface SMTPSettings {
|
||||
smtp_from_name: string;
|
||||
}
|
||||
|
||||
// 2FA / MFA interfaces
|
||||
export interface TwoFAStatus {
|
||||
totp_enabled: boolean;
|
||||
email_otp_enabled: boolean;
|
||||
backup_codes_remaining: number;
|
||||
}
|
||||
|
||||
export interface TOTPSetupResponse {
|
||||
secret: string;
|
||||
qr_code_b64: string;
|
||||
issuer: string;
|
||||
}
|
||||
|
||||
export interface TOTPEnableResponse {
|
||||
message: string;
|
||||
backup_codes: string[];
|
||||
}
|
||||
|
||||
export interface BackupCodesResponse {
|
||||
backup_codes: string[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TwoFAVerifyRequest {
|
||||
pre_auth_token: string;
|
||||
code: string;
|
||||
method: 'totp' | 'email' | 'backup';
|
||||
}
|
||||
|
||||
// OIDC interfaces
|
||||
export interface OIDCProvider {
|
||||
id: number;
|
||||
name: string;
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
scopes: string;
|
||||
is_enabled: boolean;
|
||||
auto_create_users: boolean;
|
||||
icon_url?: string | null;
|
||||
}
|
||||
|
||||
export interface OIDCProviderCreate {
|
||||
name: string;
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
scopes?: string;
|
||||
is_enabled?: boolean;
|
||||
auto_create_users?: boolean;
|
||||
icon_url?: string | null;
|
||||
}
|
||||
|
||||
export interface OIDCLink {
|
||||
id: number;
|
||||
provider_id: number;
|
||||
provider_name: string;
|
||||
provider_email?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TestSMTPRequest {
|
||||
test_recipient: string;
|
||||
}
|
||||
@@ -2504,12 +2577,106 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
// H-6: Confirm password reset using the token from the emailed link
|
||||
forgotPasswordConfirm: (token: string, newPassword: string) =>
|
||||
request<ForgotPasswordResponse>('/auth/forgot-password/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, new_password: newPassword }),
|
||||
}),
|
||||
resetUserPassword: (data: ResetPasswordRequest) =>
|
||||
request<ResetPasswordResponse>('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// 2FA - status
|
||||
get2FAStatus: () => request<TwoFAStatus>('/auth/2fa/status'),
|
||||
|
||||
// 2FA - TOTP
|
||||
setupTOTP: () => request<TOTPSetupResponse>('/auth/2fa/totp/setup', { method: 'POST' }),
|
||||
enableTOTP: (code: string) =>
|
||||
request<TOTPEnableResponse>('/auth/2fa/totp/enable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
disableTOTP: (code: string) =>
|
||||
request<{ message: string }>('/auth/2fa/totp/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
regenerateBackupCodes: (code: string) =>
|
||||
request<BackupCodesResponse>('/auth/2fa/totp/regenerate-backup-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
}),
|
||||
|
||||
// 2FA - Email OTP
|
||||
// Step 1: send a verification code to the user's email (proof of possession)
|
||||
enableEmailOTP: () =>
|
||||
request<{ message: string; setup_token: string }>('/auth/2fa/email/enable', { method: 'POST' }),
|
||||
// Step 2: confirm with the code received by email
|
||||
confirmEnableEmailOTP: (setup_token: string, code: string) =>
|
||||
request<{ message: string }>('/auth/2fa/email/enable/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ setup_token, code }),
|
||||
}),
|
||||
// Disable requires account password for re-auth
|
||||
disableEmailOTP: (password: string) =>
|
||||
request<{ message: string }>('/auth/2fa/email/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
sendEmailOTP: (preAuthToken: string) =>
|
||||
request<{ message: string; pre_auth_token?: string }>('/auth/2fa/email/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pre_auth_token: preAuthToken }),
|
||||
}),
|
||||
|
||||
// 2FA - verify (completes login)
|
||||
verify2FA: (data: TwoFAVerifyRequest) =>
|
||||
request<LoginResponse>('/auth/2fa/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// 2FA - admin
|
||||
admin2FADisable: (userId: number) =>
|
||||
request<{ message: string }>(`/auth/2fa/admin/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
// OIDC providers (public list)
|
||||
getOIDCProviders: () => request<OIDCProvider[]>('/auth/oidc/providers'),
|
||||
|
||||
// OIDC providers (admin)
|
||||
getOIDCProvidersAll: () => request<OIDCProvider[]>('/auth/oidc/providers/all'),
|
||||
createOIDCProvider: (data: OIDCProviderCreate) =>
|
||||
request<OIDCProvider>('/auth/oidc/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateOIDCProvider: (id: number, data: Partial<OIDCProviderCreate>) =>
|
||||
request<OIDCProvider>(`/auth/oidc/providers/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteOIDCProvider: (id: number) =>
|
||||
request<{ message: string }>(`/auth/oidc/providers/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// OIDC authorize URL
|
||||
getOIDCAuthorizeUrl: (providerId: number) =>
|
||||
request<{ auth_url: string }>(`/auth/oidc/authorize/${providerId}`),
|
||||
|
||||
// OIDC exchange token for JWT
|
||||
exchangeOIDCToken: (oidcToken: string) =>
|
||||
request<LoginResponse>('/auth/oidc/exchange', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ oidc_token: oidcToken }),
|
||||
}),
|
||||
|
||||
// OIDC links for current user
|
||||
getOIDCLinks: () => request<OIDCLink[]>('/auth/oidc/links'),
|
||||
deleteOIDCLink: (providerId: number) =>
|
||||
request<{ message: string }>(`/auth/oidc/links/${providerId}`, { method: 'DELETE' }),
|
||||
|
||||
// Users
|
||||
getUsers: () => request<UserResponse[]>('/users/'),
|
||||
getUser: (id: number) => request<UserResponse>(`/users/${id}`),
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import type { OIDCProvider, OIDCProviderCreate } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
const EMPTY_FORM: OIDCProviderCreate = {
|
||||
name: '',
|
||||
issuer_url: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
scopes: 'openid email profile',
|
||||
is_enabled: true,
|
||||
auto_create_users: false,
|
||||
icon_url: undefined,
|
||||
};
|
||||
|
||||
// ─── Provider form (create / edit) ───────────────────────────────────────────
|
||||
function ProviderForm({
|
||||
initial,
|
||||
isEdit = false,
|
||||
onSave,
|
||||
onCancel,
|
||||
isPending,
|
||||
}: {
|
||||
initial: OIDCProviderCreate;
|
||||
isEdit?: boolean;
|
||||
onSave: (data: OIDCProviderCreate) => void;
|
||||
onCancel: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<OIDCProviderCreate>(initial);
|
||||
const [secretChanged, setSecretChanged] = useState(false);
|
||||
const set = (key: keyof OIDCProviderCreate, value: unknown) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const inputCls =
|
||||
'w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors text-sm';
|
||||
const labelCls = 'block text-sm font-medium text-white mb-1';
|
||||
|
||||
const handleSave = () => {
|
||||
const payload = { ...form };
|
||||
if (isEdit && !secretChanged) {
|
||||
delete (payload as Partial<OIDCProviderCreate>).client_secret;
|
||||
}
|
||||
onSave(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.name')} <span className="text-red-400">*</span></label>
|
||||
<input className={inputCls} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="Google" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.issuerUrl')} <span className="text-red-400">*</span></label>
|
||||
<input className={inputCls} value={form.issuer_url} onChange={(e) => set('issuer_url', e.target.value)} placeholder="https://accounts.google.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.clientId')} <span className="text-red-400">*</span></label>
|
||||
<input className={inputCls} value={form.client_id} onChange={(e) => set('client_id', e.target.value)} placeholder="your-client-id" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
{t('settings.oidc.form.clientSecret')}
|
||||
{!isEdit && <span className="text-red-400"> *</span>}
|
||||
{isEdit && <span className="text-bambu-gray text-xs ml-1">({t('settings.oidc.form.secretHint')})</span>}
|
||||
</label>
|
||||
<input
|
||||
className={inputCls}
|
||||
type="password"
|
||||
value={secretChanged ? form.client_secret : ''}
|
||||
placeholder={isEdit && !secretChanged ? '••••••••' : t('settings.oidc.form.secretPlaceholder')}
|
||||
onChange={(e) => {
|
||||
setSecretChanged(true);
|
||||
set('client_secret', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.scopes')}</label>
|
||||
<input className={inputCls} value={form.scopes} onChange={(e) => set('scopes', e.target.value)} placeholder="openid email profile" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.iconUrl')}</label>
|
||||
<input className={inputCls} value={form.icon_url ?? ''} onChange={(e) => set('icon_url', e.target.value || undefined)} placeholder="https://..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6 pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Toggle checked={form.is_enabled ?? true} onChange={(v) => set('is_enabled', v)} />
|
||||
<span className="text-white text-sm">{t('settings.oidc.form.enabled')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Toggle checked={form.auto_create_users ?? false} onChange={(v) => set('auto_create_users', v)} />
|
||||
<div>
|
||||
<p className="text-white text-sm">{t('settings.oidc.form.autoCreate')}</p>
|
||||
<p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button variant="secondary" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export function OIDCProviderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<OIDCProvider | null>(null);
|
||||
|
||||
const { data: providers, isLoading } = useQuery({
|
||||
queryKey: ['oidc-providers-all'],
|
||||
queryFn: () => api.getOIDCProvidersAll(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
|
||||
setShowCreate(false);
|
||||
showToast(t('settings.oidc.created'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<OIDCProviderCreate> }) =>
|
||||
api.updateOIDCProvider(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
|
||||
setEditingId(null);
|
||||
showToast(t('settings.oidc.updated'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => api.deleteOIDCProvider(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
|
||||
setDeleteTarget(null);
|
||||
showToast(t('settings.oidc.deleted'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const toggleEnabled = (provider: OIDCProvider) =>
|
||||
updateMutation.mutate({ id: provider.id, data: { is_enabled: !provider.is_enabled } });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">{t('settings.oidc.title')}</h3>
|
||||
<p className="text-bambu-gray text-sm">{t('settings.oidc.desc')}</p>
|
||||
</div>
|
||||
{!showCreate && (
|
||||
<Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('settings.oidc.addProvider')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{showCreate && (
|
||||
<CardContent>
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
|
||||
<ProviderForm
|
||||
initial={EMPTY_FORM}
|
||||
onSave={(data) => createMutation.mutate(data)}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
isPending={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Provider list */}
|
||||
{providers && providers.length === 0 && !showCreate && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-center py-8 space-y-3">
|
||||
<Globe className="w-12 h-12 text-bambu-gray mx-auto" />
|
||||
<p className="text-bambu-gray">{t('settings.oidc.empty')}</p>
|
||||
<Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="inline-flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('settings.oidc.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{providers?.map((provider) => (
|
||||
<Card key={provider.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{provider.icon_url ? (
|
||||
<img src={provider.icon_url} alt={provider.name} className="w-8 h-8 rounded object-contain" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-bambu-dark-tertiary flex items-center justify-center">
|
||||
<Globe className="w-4 h-4 text-bambu-gray" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-white font-medium">{provider.name}</h4>
|
||||
{provider.is_enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full">
|
||||
<Check className="w-3 h-3" /> {t('common.enabled')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-0.5 rounded-full">
|
||||
<X className="w-3 h-3" /> {t('common.disabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
<span>{provider.issuer_url}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
checked={provider.is_enabled}
|
||||
onChange={() => toggleEnabled(provider)}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{editingId === provider.id && (
|
||||
<CardContent>
|
||||
<div className="border-t border-bambu-dark-tertiary pt-4">
|
||||
<ProviderForm
|
||||
isEdit={true}
|
||||
initial={{
|
||||
name: provider.name,
|
||||
issuer_url: provider.issuer_url,
|
||||
client_id: provider.client_id,
|
||||
client_secret: '',
|
||||
scopes: provider.scopes,
|
||||
is_enabled: provider.is_enabled,
|
||||
auto_create_users: provider.auto_create_users,
|
||||
icon_url: provider.icon_url ?? undefined,
|
||||
}}
|
||||
onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isPending={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{editingId !== provider.id && (
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<dt className="text-bambu-gray">{t('settings.oidc.form.clientId')}</dt>
|
||||
<dd className="text-white font-mono truncate">{provider.client_id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-bambu-gray">{t('settings.oidc.form.scopes')}</dt>
|
||||
<dd className="text-white">{provider.scopes}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-bambu-gray">{t('settings.oidc.form.autoCreate')}</dt>
|
||||
<dd className={provider.auto_create_users ? 'text-green-400' : 'text-bambu-gray'}>
|
||||
{provider.auto_create_users ? t('common.yes') : t('common.no')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Delete confirm */}
|
||||
{deleteTarget && (
|
||||
<ConfirmModal
|
||||
title={t('settings.oidc.deleteTitle')}
|
||||
message={t('settings.oidc.deleteMessage', { name: deleteTarget.name })}
|
||||
confirmText={t('common.delete')}
|
||||
variant="danger"
|
||||
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ShieldCheck, ShieldOff, Mail, Smartphone, Key, RefreshCw, Trash2, X, Eye, EyeOff, Copy } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// ─── Small reusable code input ────────────────────────────────────────────────
|
||||
function CodeInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
maxLength = 6,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.toUpperCase().replace(/\s/g, ''))}
|
||||
maxLength={maxLength}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors font-mono tracking-widest text-center"
|
||||
placeholder={placeholder}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Backup codes display ─────────────────────────────────────────────────────
|
||||
function BackupCodesDisplay({ codes, onDone }: { codes: string[]; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(codes.join('\n'));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
|
||||
<p className="text-amber-400 text-sm font-medium">{t('settings.twoFa.backupCodesWarning')}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{codes.map((code, index) => (
|
||||
<code key={index} className="bg-bambu-dark-secondary rounded px-3 py-2 text-center font-mono text-sm text-white tracking-widest">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={handleCopy} className="flex items-center gap-2">
|
||||
<Copy className="w-4 h-4" />
|
||||
{copied ? t('common.copied') : t('common.copy')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={onDone} className="flex-1">
|
||||
{t('settings.twoFa.savedCodes')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TOTP setup wizard ────────────────────────────────────────────────────────
|
||||
function TOTPSetupWizard({ onDone }: { onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [step, setStep] = useState<'qr' | 'confirm' | 'backup'>('qr');
|
||||
const [code, setCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
|
||||
const { data: setupData, isLoading } = useQuery({
|
||||
queryKey: ['totp-setup'],
|
||||
queryFn: () => api.setupTOTP(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (c: string) => api.enableTOTP(c),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backup_codes);
|
||||
setStep('backup');
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
},
|
||||
onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
|
||||
});
|
||||
|
||||
if (isLoading || !setupData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'qr') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.setupInstructions')}</p>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={`data:image/png;base64,${setupData.qr_code_b64}`}
|
||||
alt="TOTP QR Code"
|
||||
className="w-48 h-48 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray mb-1">{t('settings.twoFa.manualEntry')}</p>
|
||||
<div className="flex items-center gap-2 bg-bambu-dark-secondary rounded-lg px-3 py-2">
|
||||
<code className="text-white text-xs font-mono flex-1 break-all">
|
||||
{showSecret ? setupData.secret : '••••••••••••••••'}
|
||||
</code>
|
||||
<button onClick={() => setShowSecret(!showSecret)} className="text-bambu-gray hover:text-white">
|
||||
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(setupData.secret); }}
|
||||
className="text-bambu-gray hover:text-white"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" className="w-full" onClick={() => setStep('confirm')}>
|
||||
{t('settings.twoFa.scannedContinue')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirm') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.enterCodeToConfirm')}</p>
|
||||
<CodeInput value={code} onChange={setCode} placeholder="000000" />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={() => setStep('qr')} className="flex-1">
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={code.length !== 6 || enableMutation.isPending}
|
||||
onClick={() => enableMutation.mutate(code)}
|
||||
>
|
||||
{enableMutation.isPending ? t('common.saving') : t('settings.twoFa.activate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// step === 'backup'
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-white font-medium">{t('settings.twoFa.backupCodesTitle')}</h3>
|
||||
<BackupCodesDisplay codes={backupCodes} onDone={onDone} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export function TwoFactorSettings() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [showTOTPSetup, setShowTOTPSetup] = useState(false);
|
||||
const [showDisableTOTP, setShowDisableTOTP] = useState(false);
|
||||
const [showRegenBackup, setShowRegenBackup] = useState(false);
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [regenCode, setRegenCode] = useState('');
|
||||
const [newBackupCodes, setNewBackupCodes] = useState<string[] | null>(null);
|
||||
|
||||
// Email OTP enable: two-step proof-of-possession flow
|
||||
const [emailSetupToken, setEmailSetupToken] = useState<string | null>(null);
|
||||
const [emailSetupCode, setEmailSetupCode] = useState('');
|
||||
|
||||
// Email OTP disable: requires account password
|
||||
const [showDisableEmail, setShowDisableEmail] = useState(false);
|
||||
const [emailDisablePassword, setEmailDisablePassword] = useState('');
|
||||
const [showEmailDisablePassword, setShowEmailDisablePassword] = useState(false);
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['2fa-status'],
|
||||
queryFn: () => api.get2FAStatus(),
|
||||
});
|
||||
|
||||
const { data: oidcLinks } = useQuery({
|
||||
queryKey: ['oidc-links'],
|
||||
queryFn: () => api.getOIDCLinks(),
|
||||
});
|
||||
|
||||
// Step 1: request verification code (proof of possession)
|
||||
const enableEmailRequestMutation = useMutation({
|
||||
mutationFn: () => api.enableEmailOTP(),
|
||||
onSuccess: (data: { message: string; setup_token: string }) => {
|
||||
setEmailSetupToken(data.setup_token);
|
||||
showToast(data.message, 'success');
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
const msg = e.message ?? '';
|
||||
if (msg.toLowerCase().includes('smtp')) {
|
||||
showToast(t('settings.twoFa.smtpRequired'), 'error');
|
||||
} else {
|
||||
showToast(msg, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Step 2: confirm with the code received by email
|
||||
const enableEmailConfirmMutation = useMutation({
|
||||
mutationFn: () => api.confirmEnableEmailOTP(emailSetupToken!, emailSetupCode),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
setEmailSetupToken(null);
|
||||
setEmailSetupCode('');
|
||||
showToast(t('settings.twoFa.emailOtpEnabled'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const disableEmailMutation = useMutation({
|
||||
mutationFn: (password: string) => api.disableEmailOTP(password),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
setShowDisableEmail(false);
|
||||
setEmailDisablePassword('');
|
||||
showToast(t('settings.twoFa.emailOtpDisabled'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
const disableTOTPMutation = useMutation({
|
||||
mutationFn: (code: string) => api.disableTOTP(code),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
setShowDisableTOTP(false);
|
||||
setDisableCode('');
|
||||
showToast(t('settings.twoFa.totpDisabled'), 'success');
|
||||
},
|
||||
onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
|
||||
});
|
||||
|
||||
const regenMutation = useMutation({
|
||||
mutationFn: (code: string) => api.regenerateBackupCodes(code),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
|
||||
setShowRegenBackup(false);
|
||||
setRegenCode('');
|
||||
setNewBackupCodes(data.backup_codes);
|
||||
},
|
||||
onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
|
||||
});
|
||||
|
||||
const unlinkOIDCMutation = useMutation({
|
||||
mutationFn: (providerId: number) => api.deleteOIDCLink(providerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-links'] });
|
||||
showToast(t('settings.twoFa.oidcUnlinked'), 'success');
|
||||
},
|
||||
onError: (e: Error) => showToast(e.message, 'error'),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasEmail = !!user?.email;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── TOTP ─────────────────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.totp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
|
||||
<Smartphone className={`w-5 h-5 ${status?.totp_enabled ? 'text-green-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">{t('settings.twoFa.totpTitle')}</h3>
|
||||
<p className="text-bambu-gray text-sm">{t('settings.twoFa.totpDesc')}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
{status?.totp_enabled ? (
|
||||
<span className="flex items-center gap-1 text-green-400 text-sm font-medium">
|
||||
<ShieldCheck className="w-4 h-4" /> {t('common.enabled')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-bambu-gray text-sm">
|
||||
<ShieldOff className="w-4 h-4" /> {t('common.disabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* TOTP Setup wizard */}
|
||||
{showTOTPSetup ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-white font-medium">{t('settings.twoFa.setupAuthApp')}</h4>
|
||||
<button onClick={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} className="text-bambu-gray hover:text-white">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<TOTPSetupWizard onDone={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} />
|
||||
</div>
|
||||
) : showDisableTOTP ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.disableConfirmHint')}</p>
|
||||
<CodeInput value={disableCode} onChange={setDisableCode} placeholder="000000 or XXXXXXXX" maxLength={8} />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={() => { setShowDisableTOTP(false); setDisableCode(''); }} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={disableCode.length < 6 || disableTOTPMutation.isPending}
|
||||
onClick={() => disableTOTPMutation.mutate(disableCode)}
|
||||
>
|
||||
{disableTOTPMutation.isPending ? t('common.saving') : t('settings.twoFa.disableTotp')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : showRegenBackup ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.regenBackupHint')}</p>
|
||||
<CodeInput value={regenCode} onChange={setRegenCode} placeholder="000000 or XXXXXXXX" maxLength={8} />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={() => { setShowRegenBackup(false); setRegenCode(''); }} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={regenCode.length < 6 || regenMutation.isPending}
|
||||
onClick={() => regenMutation.mutate(regenCode)}
|
||||
>
|
||||
{regenMutation.isPending ? t('common.saving') : t('settings.twoFa.regenBackup')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : newBackupCodes ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-white font-medium">{t('settings.twoFa.newBackupCodes')}</h4>
|
||||
<BackupCodesDisplay codes={newBackupCodes} onDone={() => setNewBackupCodes(null)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{!status?.totp_enabled ? (
|
||||
<Button variant="primary" onClick={() => setShowTOTPSetup(true)} className="flex items-center gap-2">
|
||||
<Smartphone className="w-4 h-4" />
|
||||
{t('settings.twoFa.setupTotp')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray-light">
|
||||
<Key className="w-4 h-4" />
|
||||
{t('settings.twoFa.backupCodesRemaining', { count: status.backup_codes_remaining })}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowRegenBackup(true)} className="flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{t('settings.twoFa.regenBackup')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setShowDisableTOTP(true)} className="flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('settings.twoFa.disableTotp')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Email OTP ─────────────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.email_otp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
|
||||
<Mail className={`w-5 h-5 ${status?.email_otp_enabled ? 'text-green-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-semibold">{t('settings.twoFa.emailOtpTitle')}</h3>
|
||||
<p className="text-bambu-gray text-sm">
|
||||
{hasEmail
|
||||
? t('settings.twoFa.emailOtpDesc', { email: user?.email })
|
||||
: t('settings.twoFa.emailOtpNoEmail')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Show status badge; enable/disable handled in CardContent */}
|
||||
<div className="ml-auto">
|
||||
{status?.email_otp_enabled ? (
|
||||
<span className="flex items-center gap-1 text-green-400 text-sm font-medium">
|
||||
<ShieldCheck className="w-4 h-4" /> {t('common.enabled')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-bambu-gray text-sm">
|
||||
<ShieldOff className="w-4 h-4" /> {t('common.disabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasEmail ? (
|
||||
<p className="text-amber-400 text-sm">{t('settings.twoFa.addEmailFirst')}</p>
|
||||
) : emailSetupToken ? (
|
||||
/* Step 2: enter the code that was sent to the email */
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.emailSetupEnterCode')}</p>
|
||||
<CodeInput value={emailSetupCode} onChange={setEmailSetupCode} placeholder="000000" />
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => { setEmailSetupToken(null); setEmailSetupCode(''); }}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
disabled={emailSetupCode.length !== 6 || enableEmailConfirmMutation.isPending}
|
||||
onClick={() => enableEmailConfirmMutation.mutate()}
|
||||
>
|
||||
{enableEmailConfirmMutation.isPending ? t('common.saving') : t('settings.twoFa.verifyAndEnable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : showDisableEmail ? (
|
||||
/* Disable: require account password for re-auth */
|
||||
<div className="space-y-4">
|
||||
<p className="text-bambu-gray-light text-sm">{t('settings.twoFa.emailDisablePasswordHint')}</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showEmailDisablePassword ? 'text' : 'password'}
|
||||
value={emailDisablePassword}
|
||||
onChange={(e) => setEmailDisablePassword(e.target.value)}
|
||||
placeholder={t('settings.twoFa.passwordPlaceholder')}
|
||||
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailDisablePassword(!showEmailDisablePassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
|
||||
>
|
||||
{showEmailDisablePassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => { setShowDisableEmail(false); setEmailDisablePassword(''); }}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={!emailDisablePassword || disableEmailMutation.isPending}
|
||||
onClick={() => disableEmailMutation.mutate(emailDisablePassword)}
|
||||
>
|
||||
{disableEmailMutation.isPending ? t('common.saving') : t('settings.twoFa.disableEmailOtp')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
{!status?.email_otp_enabled ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!hasEmail || enableEmailRequestMutation.isPending}
|
||||
onClick={() => enableEmailRequestMutation.mutate()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{enableEmailRequestMutation.isPending ? t('common.saving') : t('settings.twoFa.enableEmailOtp')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setShowDisableEmail(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t('settings.twoFa.disableEmailOtp')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Linked SSO accounts ───────────────────────────────────────────── */}
|
||||
{oidcLinks && oidcLinks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-white font-semibold">{t('settings.twoFa.linkedAccounts')}</h3>
|
||||
<p className="text-bambu-gray text-sm">{t('settings.twoFa.linkedAccountsDesc')}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{oidcLinks.map((link) => (
|
||||
<div key={link.id} className="flex items-center justify-between py-2 border-b border-bambu-dark-tertiary last:border-0">
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{link.provider_name}</p>
|
||||
{link.provider_email && (
|
||||
<p className="text-bambu-gray text-xs">{link.provider_email}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => unlinkOIDCMutation.mutate(link.provider_id)}
|
||||
disabled={unlinkOIDCMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, getAuthToken, setAuthToken } from '../api/client';
|
||||
import type { Permission, UserResponse } from '../api/client';
|
||||
import type { LoginResponse, Permission, UserResponse } from '../api/client';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserResponse | null;
|
||||
@@ -8,7 +8,10 @@ interface AuthContextType {
|
||||
requiresSetup: boolean;
|
||||
loading: boolean;
|
||||
isAdmin: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
/** Login with username/password. Returns LoginResponse (may include requires_2fa). */
|
||||
login: (username: string, password: string) => Promise<LoginResponse>;
|
||||
/** Finalise login after 2FA or OIDC — store token and set user directly. */
|
||||
loginWithToken: (token: string, user: UserResponse) => void;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
refreshAuth: () => Promise<void>;
|
||||
@@ -30,12 +33,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
// Bootstrap: if URL has ?token= param, store it and strip from URL.
|
||||
// Allows SpoolBuddy kiosk to pass API key via URL on first load.
|
||||
// Bootstrap: if URL has ?token= param, store it session-only first and
|
||||
// strip it from the URL. Allows SpoolBuddy kiosk to pass an API key via
|
||||
// URL on first load. Persistence to localStorage is deferred until the
|
||||
// token has been verified by the server (L-4: prevents session fixation
|
||||
// where an attacker-crafted URL immediately persists a forged/stolen token).
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlToken = urlParams.get('token');
|
||||
if (urlToken) {
|
||||
setAuthToken(urlToken);
|
||||
setAuthToken(urlToken, false); // session-only until server confirms it's valid
|
||||
urlParams.delete('token');
|
||||
const cleanSearch = urlParams.toString();
|
||||
const cleanUrl = window.location.pathname
|
||||
@@ -56,8 +62,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const currentUser = await api.getCurrentUser();
|
||||
if (!mountedRef.current) return;
|
||||
setUser(currentUser);
|
||||
// Persist kiosk token only after the server confirms it is valid.
|
||||
if (urlToken && token === urlToken) {
|
||||
setAuthToken(urlToken, true);
|
||||
}
|
||||
} catch {
|
||||
// Token invalid, clear it
|
||||
// Token invalid, clear it (removes from both sessionStorage and localStorage)
|
||||
setAuthToken(null);
|
||||
if (!mountedRef.current) return;
|
||||
setUser(null);
|
||||
@@ -106,10 +116,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [loading, requiresSetup, authEnabled]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const login = async (username: string, password: string): Promise<LoginResponse> => {
|
||||
const response = await api.login({ username, password });
|
||||
setAuthToken(response.access_token);
|
||||
await checkAuthStatus();
|
||||
if (!response.requires_2fa && response.access_token) {
|
||||
setAuthToken(response.access_token);
|
||||
await checkAuthStatus();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const loginWithToken = (token: string, userObj: UserResponse) => {
|
||||
setAuthToken(token);
|
||||
setUser(userObj);
|
||||
setAuthEnabled(true);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
@@ -205,6 +224,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
loading,
|
||||
isAdmin,
|
||||
login,
|
||||
loginWithToken,
|
||||
logout,
|
||||
refreshUser,
|
||||
refreshAuth,
|
||||
|
||||
@@ -102,6 +102,9 @@ export default {
|
||||
more: '+{{count}} weitere',
|
||||
ascending: 'Aufsteigend',
|
||||
descending: 'Absteigend',
|
||||
back: 'Zurück',
|
||||
copy: 'Kopieren',
|
||||
copied: 'Kopiert!',
|
||||
printer: 'Drucker',
|
||||
remove: 'Entfernen',
|
||||
type: 'Typ',
|
||||
@@ -1325,6 +1328,8 @@ export default {
|
||||
backup: 'Sicherung',
|
||||
emailAuth: 'E-Mail-Authentifizierung',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Zwei-Faktor-Auth',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
spoolbuddy: {
|
||||
infoTitle: 'SpoolBuddy-Geräte',
|
||||
@@ -2078,6 +2083,74 @@ export default {
|
||||
deleteUserAndItems: 'Benutzer UND dessen Elemente löschen',
|
||||
deleteUserKeepItems: 'Benutzer löschen, Elemente behalten (werden herrenlos)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'Authenticator-App (TOTP)',
|
||||
totpDesc: 'Verwende eine Authenticator-App wie Google Authenticator, Aegis oder Authy.',
|
||||
emailOtpTitle: 'E-Mail OTP',
|
||||
emailOtpDesc: 'Sende einen Einmalcode an {{email}} beim Einloggen.',
|
||||
emailOtpNoEmail: 'Füge eine E-Mail-Adresse zu deinem Konto hinzu, um diese Methode zu aktivieren.',
|
||||
addEmailFirst: 'Dein Konto hat keine E-Mail-Adresse. Bitte einen Administrator, eine hinzuzufügen.',
|
||||
setupTotp: 'Authenticator-App einrichten',
|
||||
setupAuthApp: 'Authenticator-App einrichten',
|
||||
setupInstructions: 'Scanne den QR-Code mit deiner Authenticator-App und bestätige mit einem Code.',
|
||||
manualEntry: 'Kein Scanner? Gib dieses Secret manuell ein:',
|
||||
scannedContinue: 'Code gescannt — weiter',
|
||||
enterCodeToConfirm: 'Gib den 6-stelligen Code aus deiner Authenticator-App ein, um die Einrichtung zu bestätigen.',
|
||||
activate: 'Aktivieren',
|
||||
disableTotp: 'Authenticator deaktivieren',
|
||||
disableConfirmHint: 'Gib einen gültigen TOTP-Code oder einen Backup-Code ein, um den Authenticator zu deaktivieren.',
|
||||
totpDisabled: 'Authenticator-App deaktiviert.',
|
||||
emailOtpEnabled: 'E-Mail OTP aktiviert.',
|
||||
emailOtpDisabled: 'E-Mail OTP deaktiviert.',
|
||||
smtpRequired: 'Bitte konfigurieren und testen Sie zuerst die SMTP-Einstellungen.',
|
||||
invalidCode: 'Ungültiger Code. Bitte erneut versuchen.',
|
||||
enableEmailOtp: 'E-Mail OTP aktivieren',
|
||||
disableEmailOtp: 'E-Mail OTP deaktivieren',
|
||||
emailSetupEnterCode: 'Ein Bestätigungscode wurde an Ihre E-Mail-Adresse gesendet. Geben Sie ihn unten ein, um zu bestätigen, dass Ihnen dieses Postfach gehört.',
|
||||
verifyAndEnable: 'Verifizieren & Aktivieren',
|
||||
emailDisablePasswordHint: 'Geben Sie Ihr Kontopasswort ein, um die Deaktivierung des E-Mail OTP zu bestätigen.',
|
||||
passwordPlaceholder: 'Passwort eingeben',
|
||||
backupCodesTitle: 'Backup-Codes sichern',
|
||||
backupCodesWarning: 'Speichere diese Codes sicher. Jeder Code kann nur einmal verwendet werden und wird nicht erneut angezeigt.',
|
||||
backupCodesRemaining: '{{count}} Backup-Codes verbleibend',
|
||||
savedCodes: 'Codes gespeichert',
|
||||
regenBackup: 'Backup-Codes neu generieren',
|
||||
regenBackupHint: 'Gib deinen aktuellen TOTP-Code ein, um 10 neue Backup-Codes zu generieren. Alle bestehenden Codes werden ungültig.',
|
||||
newBackupCodes: 'Neue Backup-Codes',
|
||||
linkedAccounts: 'Verknüpfte SSO-Konten',
|
||||
linkedAccountsDesc: 'Diese externen Identitätsanbieter sind mit deinem Konto verknüpft.',
|
||||
oidcUnlinked: 'Konto getrennt.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'SSO / OIDC-Anbieter',
|
||||
desc: 'Konfiguriere OpenID Connect-Anbieter für Single Sign-On.',
|
||||
addProvider: 'Anbieter hinzufügen',
|
||||
newProvider: 'Neuer Anbieter',
|
||||
empty: 'Noch keine OIDC-Anbieter konfiguriert.',
|
||||
created: 'Anbieter erstellt.',
|
||||
updated: 'Anbieter aktualisiert.',
|
||||
deleted: 'Anbieter gelöscht.',
|
||||
deleteTitle: 'Anbieter löschen',
|
||||
deleteMessage: '"{{name}}" löschen? Alle verknüpften Benutzerkonten werden getrennt.',
|
||||
form: {
|
||||
name: 'Anzeigename',
|
||||
issuerUrl: 'Aussteller-URL',
|
||||
clientId: 'Client-ID',
|
||||
clientSecret: 'Client-Secret',
|
||||
scopes: 'Scopes',
|
||||
iconUrl: 'Symbol-URL (optional)',
|
||||
enabled: 'Aktiviert',
|
||||
autoCreate: 'Benutzer automatisch anlegen',
|
||||
autoCreateDesc: 'Erstellt beim ersten Login automatisch ein lokales Konto.',
|
||||
secretHint: 'leer lassen zum Beibehalten',
|
||||
secretPlaceholder: 'neues Secret',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
@@ -2208,6 +2281,28 @@ export default {
|
||||
loginSuccess: 'Erfolgreich angemeldet',
|
||||
loginFailed: 'Anmeldung fehlgeschlagen',
|
||||
enterCredentials: 'Bitte Benutzername und Passwort eingeben',
|
||||
enterEmail: 'Bitte geben Sie Ihre E-Mail-Adresse ein',
|
||||
oidcLoginFailed: 'OIDC-Anmeldung fehlgeschlagen',
|
||||
oidcErrors: {
|
||||
providerError: 'Der Identity-Provider hat einen Fehler zurückgegeben',
|
||||
missingParameters: 'Dem OIDC-Callback fehlen erforderliche Parameter',
|
||||
invalidState: 'OIDC-State ist ungültig oder wurde bereits verwendet',
|
||||
stateExpired: 'OIDC-Sitzung abgelaufen — bitte erneut versuchen',
|
||||
providerNotFound: 'OIDC-Provider nicht gefunden',
|
||||
discoveryFailed: 'OIDC-Discovery-Dokument konnte nicht abgerufen werden',
|
||||
invalidDiscovery: 'OIDC-Discovery-Dokument ist ungültig',
|
||||
networkError: 'Netzwerkfehler beim OIDC-Token-Austausch',
|
||||
badResponse: 'Unerwartete Antwort beim OIDC-Token-Austausch',
|
||||
noIdToken: 'OIDC-Provider hat kein ID-Token zurückgegeben',
|
||||
validationFailed: 'OIDC-Token-Validierung fehlgeschlagen',
|
||||
nonceMismatch: 'OIDC-Nonce stimmt nicht überein — möglicher Replay-Angriff',
|
||||
missingSubClaim: 'OIDC-Token enthält keinen Sub-Claim',
|
||||
noLinkedAccount: 'Kein lokales Konto mit dieser OIDC-Identität verknüpft',
|
||||
accountInactive: 'Ihr Konto ist inaktiv',
|
||||
userResolutionFailed: 'Ihr Konto konnte nicht aufgelöst werden',
|
||||
internalError: 'Interner Fehler beim OIDC-Login',
|
||||
tokenExchangeFailed: 'OIDC-Token-Austausch fehlgeschlagen',
|
||||
},
|
||||
forgotPasswordTitle: 'Passwort vergessen',
|
||||
forgotPasswordMessage: 'Wenn Sie Ihr Passwort vergessen haben, wenden Sie sich bitte an Ihren Systemadministrator.',
|
||||
forgotPasswordEmailMessage: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen ein neues Passwort.',
|
||||
@@ -2222,6 +2317,47 @@ export default {
|
||||
resetStep3: 'Er kann ein neues temporäres Passwort für Sie festlegen',
|
||||
resetStep4: 'Melden Sie sich mit dem neuen Passwort an und ändern Sie es in den Einstellungen',
|
||||
gotIt: 'Verstanden',
|
||||
resetPassword: {
|
||||
title: 'Neues Passwort festlegen',
|
||||
subtitle: 'Geben Sie unten Ihr neues Passwort ein und bestätigen Sie es.',
|
||||
newPassword: 'Neues Passwort',
|
||||
newPasswordPlaceholder: 'Mindestens 8 Zeichen',
|
||||
confirmPassword: 'Passwort bestätigen',
|
||||
confirmPasswordPlaceholder: 'Neues Passwort wiederholen',
|
||||
saving: 'Wird gespeichert\u2026',
|
||||
submit: 'Neues Passwort festlegen',
|
||||
backToLogin: 'Zurück zur Anmeldung',
|
||||
passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
|
||||
passwordTooShort: 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||
resetFailed: 'Passwort zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
|
||||
},
|
||||
twoFA: {
|
||||
title: 'Zwei-Faktor-Authentifizierung',
|
||||
subtitle: 'Ihr Konto ist mit 2FA geschützt. Geben Sie unten den Bestätigungscode ein.',
|
||||
methodAuthenticator: 'Authenticator-App',
|
||||
methodEmail: 'E-Mail-Code',
|
||||
methodBackup: 'Wiederherstellungscode',
|
||||
instructionsTotp: 'Öffnen Sie Ihre Authenticator-App und geben Sie den 6-stelligen Code für Bambuddy ein.',
|
||||
instructionsEmail: 'Ein 6-stelliger Code wurde an Ihre E-Mail-Adresse gesendet. Er ist 10 Minuten gültig.',
|
||||
instructionsEmailNotSent: 'Klicken Sie unten, um einen Bestätigungscode per E-Mail zu erhalten.',
|
||||
instructionsBackup: 'Geben Sie einen Ihrer 8-stelligen Wiederherstellungscodes ein. Jeder Code kann nur einmal verwendet werden.',
|
||||
sendCodeButton: 'Code per E-Mail senden',
|
||||
sendingCode: 'Wird gesendet...',
|
||||
resendCode: 'Code erneut senden',
|
||||
codeLabel: 'Bestätigungscode',
|
||||
backupCodeLabel: 'Wiederherstellungscode',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Bestätigen',
|
||||
verifyingButton: 'Wird überprüft...',
|
||||
backToLogin: '← Zurück zur Anmeldung',
|
||||
orContinueWith: 'oder anmelden mit',
|
||||
signInWith: 'Anmelden mit {{provider}}',
|
||||
enterCode: 'Bitte geben Sie den Bestätigungscode ein',
|
||||
sendCodeFailed: 'Bestätigungscode konnte nicht gesendet werden',
|
||||
invalidCode: 'Ungültiger Code. Bitte erneut versuchen.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
||||
@@ -102,6 +102,9 @@ export default {
|
||||
more: '+{{count}} more',
|
||||
ascending: 'Ascending',
|
||||
descending: 'Descending',
|
||||
back: 'Back',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied!',
|
||||
printer: 'Printer',
|
||||
remove: 'Remove',
|
||||
type: 'Type',
|
||||
@@ -1326,6 +1329,8 @@ export default {
|
||||
backup: 'Backup',
|
||||
emailAuth: 'Email Authentication',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Two-Factor Auth',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
spoolbuddy: {
|
||||
infoTitle: 'SpoolBuddy devices',
|
||||
@@ -2080,6 +2085,74 @@ export default {
|
||||
deleteUserAndItems: 'Delete user AND their items',
|
||||
deleteUserKeepItems: 'Delete user, keep items (become ownerless)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'Authenticator App (TOTP)',
|
||||
totpDesc: 'Use an authenticator app like Google Authenticator, Aegis or Authy.',
|
||||
emailOtpTitle: 'Email OTP',
|
||||
emailOtpDesc: 'Send a one-time code to {{email}} when you log in.',
|
||||
emailOtpNoEmail: 'Add an email address to your account to enable this method.',
|
||||
addEmailFirst: 'Your account has no email address. Ask an admin to add one before enabling Email OTP.',
|
||||
setupTotp: 'Set up Authenticator App',
|
||||
setupAuthApp: 'Set up Authenticator App',
|
||||
setupInstructions: 'Scan the QR code below with your authenticator app, then confirm with a code.',
|
||||
manualEntry: 'Can\'t scan? Enter this secret manually:',
|
||||
scannedContinue: 'I\'ve scanned the code — continue',
|
||||
enterCodeToConfirm: 'Enter the 6-digit code from your authenticator app to confirm setup.',
|
||||
activate: 'Activate',
|
||||
disableTotp: 'Disable Authenticator',
|
||||
disableConfirmHint: 'Enter a valid TOTP code or a backup code to disable the authenticator.',
|
||||
totpDisabled: 'Authenticator app disabled.',
|
||||
emailOtpEnabled: 'Email OTP enabled.',
|
||||
emailOtpDisabled: 'Email OTP disabled.',
|
||||
smtpRequired: 'Please configure and test SMTP settings first.',
|
||||
invalidCode: 'Invalid code. Please try again.',
|
||||
enableEmailOtp: 'Enable Email OTP',
|
||||
disableEmailOtp: 'Disable Email OTP',
|
||||
emailSetupEnterCode: 'A verification code has been sent to your email address. Enter it below to confirm you own this inbox.',
|
||||
verifyAndEnable: 'Verify & Enable',
|
||||
emailDisablePasswordHint: 'Enter your account password to confirm disabling email OTP.',
|
||||
passwordPlaceholder: 'Enter your password',
|
||||
backupCodesTitle: 'Save your backup codes',
|
||||
backupCodesWarning: 'Save these codes somewhere safe. Each code can only be used once and they will not be shown again.',
|
||||
backupCodesRemaining: '{{count}} backup codes remaining',
|
||||
savedCodes: 'I\'ve saved my codes',
|
||||
regenBackup: 'Regenerate Backup Codes',
|
||||
regenBackupHint: 'Enter your current TOTP code to generate 10 new backup codes. All existing backup codes will be invalidated.',
|
||||
newBackupCodes: 'New backup codes',
|
||||
linkedAccounts: 'Linked SSO Accounts',
|
||||
linkedAccountsDesc: 'These external identity providers are linked to your account.',
|
||||
oidcUnlinked: 'Account unlinked.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'SSO / OIDC Providers',
|
||||
desc: 'Configure OpenID Connect providers to allow single sign-on via external identity providers.',
|
||||
addProvider: 'Add Provider',
|
||||
newProvider: 'New Provider',
|
||||
empty: 'No OIDC providers configured yet.',
|
||||
created: 'Provider created.',
|
||||
updated: 'Provider updated.',
|
||||
deleted: 'Provider deleted.',
|
||||
deleteTitle: 'Delete Provider',
|
||||
deleteMessage: 'Delete "{{name}}"? All linked user accounts will be disconnected.',
|
||||
form: {
|
||||
name: 'Display Name',
|
||||
issuerUrl: 'Issuer URL',
|
||||
clientId: 'Client ID',
|
||||
clientSecret: 'Client Secret',
|
||||
scopes: 'Scopes',
|
||||
iconUrl: 'Icon URL (optional)',
|
||||
enabled: 'Enabled',
|
||||
autoCreate: 'Auto-create users',
|
||||
autoCreateDesc: 'Automatically create a local account on first login.',
|
||||
secretHint: 'leave blank to keep current',
|
||||
secretPlaceholder: 'new secret',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
@@ -2210,6 +2283,28 @@ export default {
|
||||
loginSuccess: 'Logged in successfully',
|
||||
loginFailed: 'Login failed',
|
||||
enterCredentials: 'Please enter username and password',
|
||||
enterEmail: 'Please enter your email address',
|
||||
oidcLoginFailed: 'OIDC login failed',
|
||||
oidcErrors: {
|
||||
providerError: 'The identity provider returned an error',
|
||||
missingParameters: 'OIDC callback is missing required parameters',
|
||||
invalidState: 'OIDC state is invalid or has already been used',
|
||||
stateExpired: 'OIDC login session expired — please try again',
|
||||
providerNotFound: 'OIDC provider not found',
|
||||
discoveryFailed: 'Failed to fetch OIDC discovery document',
|
||||
invalidDiscovery: 'OIDC discovery document is invalid',
|
||||
networkError: 'Network error during OIDC token exchange',
|
||||
badResponse: 'Unexpected response during OIDC token exchange',
|
||||
noIdToken: 'OIDC provider did not return an ID token',
|
||||
validationFailed: 'OIDC token validation failed',
|
||||
nonceMismatch: 'OIDC nonce mismatch — possible replay attack',
|
||||
missingSubClaim: 'OIDC token is missing the sub claim',
|
||||
noLinkedAccount: 'No local account is linked to this OIDC identity',
|
||||
accountInactive: 'Your account is inactive',
|
||||
userResolutionFailed: 'Failed to resolve your account',
|
||||
internalError: 'An internal error occurred during OIDC login',
|
||||
tokenExchangeFailed: 'OIDC token exchange failed',
|
||||
},
|
||||
forgotPasswordTitle: 'Forgot Password',
|
||||
forgotPasswordMessage: "If you've forgotten your password, please contact your system administrator to reset it.",
|
||||
forgotPasswordEmailMessage: "Enter your email address and we'll send you a new password.",
|
||||
@@ -2224,6 +2319,47 @@ export default {
|
||||
resetStep3: 'They can set a new temporary password for you',
|
||||
resetStep4: 'Log in with the new password and change it in Settings',
|
||||
gotIt: 'Got it',
|
||||
resetPassword: {
|
||||
title: 'Set New Password',
|
||||
subtitle: 'Enter and confirm your new password below.',
|
||||
newPassword: 'New Password',
|
||||
newPasswordPlaceholder: 'At least 8 characters',
|
||||
confirmPassword: 'Confirm Password',
|
||||
confirmPasswordPlaceholder: 'Repeat new password',
|
||||
saving: 'Saving\u2026',
|
||||
submit: 'Set New Password',
|
||||
backToLogin: 'Back to login',
|
||||
passwordsDoNotMatch: 'Passwords do not match',
|
||||
passwordTooShort: 'Password must be at least 8 characters',
|
||||
resetFailed: 'Password reset failed. The link may have expired.',
|
||||
},
|
||||
twoFA: {
|
||||
title: 'Two-Factor Authentication',
|
||||
subtitle: 'Your account is protected with 2FA. Enter the verification code below.',
|
||||
methodAuthenticator: 'Authenticator App',
|
||||
methodEmail: 'Email Code',
|
||||
methodBackup: 'Backup Code',
|
||||
instructionsTotp: 'Open your authenticator app and enter the 6-digit code for Bambuddy.',
|
||||
instructionsEmail: 'A 6-digit code has been sent to your email address. It expires in 10 minutes.',
|
||||
instructionsEmailNotSent: 'Click the button below to receive a verification code via email.',
|
||||
instructionsBackup: 'Enter one of your 8-character backup recovery codes. Each code can only be used once.',
|
||||
sendCodeButton: 'Send Code via Email',
|
||||
sendingCode: 'Sending...',
|
||||
resendCode: 'Resend code',
|
||||
codeLabel: 'Verification Code',
|
||||
backupCodeLabel: 'Backup Code',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Verify',
|
||||
verifyingButton: 'Verifying...',
|
||||
backToLogin: '← Back to login',
|
||||
orContinueWith: 'or continue with',
|
||||
signInWith: 'Sign in with {{provider}}',
|
||||
enterCode: 'Please enter the verification code',
|
||||
sendCodeFailed: 'Failed to send verification code',
|
||||
invalidCode: 'Invalid code. Please try again.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
||||
@@ -102,6 +102,9 @@ export default {
|
||||
more: '+{{count}} de plus',
|
||||
ascending: 'Croissant',
|
||||
descending: 'Décroissant',
|
||||
back: 'Retour',
|
||||
copy: 'Copier',
|
||||
copied: 'Copié !',
|
||||
printer: 'Imprimante',
|
||||
remove: 'Retirer',
|
||||
type: 'Type',
|
||||
@@ -1324,6 +1327,8 @@ export default {
|
||||
backup: 'Sauvegarde',
|
||||
emailAuth: 'Authentification Email',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Authentification 2FA',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
ldap: {
|
||||
title: 'Authentification LDAP',
|
||||
@@ -2039,6 +2044,74 @@ export default {
|
||||
deleteUserAndItems: 'Supprimer l\'utilisateur ET ses éléments',
|
||||
deleteUserKeepItems: 'Supprimer l\'utilisateur, garder les éléments (deviennent sans propriétaire)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'Application Authenticator (TOTP)',
|
||||
totpDesc: 'Utilisez une application comme Google Authenticator, Aegis ou Authy.',
|
||||
emailOtpTitle: 'OTP par e-mail',
|
||||
emailOtpDesc: 'Envoyez un code à usage unique à {{email}} lors de la connexion.',
|
||||
emailOtpNoEmail: 'Ajoutez une adresse e-mail à votre compte pour activer cette méthode.',
|
||||
addEmailFirst: 'Votre compte n\'a pas d\'adresse e-mail. Demandez à un administrateur d\'en ajouter une.',
|
||||
setupTotp: 'Configurer l\'application Authenticator',
|
||||
setupAuthApp: 'Configurer l\'application Authenticator',
|
||||
setupInstructions: 'Scannez le QR code avec votre application authenticator, puis confirmez avec un code.',
|
||||
manualEntry: 'Impossible de scanner ? Entrez ce secret manuellement :',
|
||||
scannedContinue: 'Code scanné — continuer',
|
||||
enterCodeToConfirm: 'Entrez le code à 6 chiffres de votre application authenticator pour confirmer.',
|
||||
activate: 'Activer',
|
||||
disableTotp: 'Désactiver l\'Authenticator',
|
||||
disableConfirmHint: 'Entrez un code TOTP valide ou un code de secours pour désactiver l\'authenticator.',
|
||||
totpDisabled: 'Application Authenticator désactivée.',
|
||||
emailOtpEnabled: 'OTP par e-mail activé.',
|
||||
emailOtpDisabled: 'OTP par e-mail désactivé.',
|
||||
smtpRequired: 'Veuillez d\'abord configurer et tester les paramètres SMTP.',
|
||||
invalidCode: 'Code invalide. Veuillez réessayer.',
|
||||
enableEmailOtp: 'Activer OTP par e-mail',
|
||||
disableEmailOtp: 'Désactiver OTP par e-mail',
|
||||
emailSetupEnterCode: 'Un code de vérification a été envoyé à votre adresse e-mail. Entrez-le ci-dessous pour confirmer que vous possédez cette boîte de réception.',
|
||||
verifyAndEnable: 'Vérifier et activer',
|
||||
emailDisablePasswordHint: 'Entrez le mot de passe de votre compte pour confirmer la désactivation de l\'OTP par e-mail.',
|
||||
passwordPlaceholder: 'Entrez votre mot de passe',
|
||||
backupCodesTitle: 'Sauvegardez vos codes de secours',
|
||||
backupCodesWarning: 'Conservez ces codes en lieu sûr. Chaque code ne peut être utilisé qu\'une seule fois et ne sera plus affiché.',
|
||||
backupCodesRemaining: '{{count}} codes de secours restants',
|
||||
savedCodes: 'Codes sauvegardés',
|
||||
regenBackup: 'Régénérer les codes de secours',
|
||||
regenBackupHint: 'Entrez votre code TOTP actuel pour générer 10 nouveaux codes de secours. Tous les codes existants seront invalidés.',
|
||||
newBackupCodes: 'Nouveaux codes de secours',
|
||||
linkedAccounts: 'Comptes SSO liés',
|
||||
linkedAccountsDesc: 'Ces fournisseurs d\'identité externes sont liés à votre compte.',
|
||||
oidcUnlinked: 'Compte dissocié.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'Fournisseurs SSO / OIDC',
|
||||
desc: 'Configurez des fournisseurs OpenID Connect pour l\'authentification unique.',
|
||||
addProvider: 'Ajouter un fournisseur',
|
||||
newProvider: 'Nouveau fournisseur',
|
||||
empty: 'Aucun fournisseur OIDC configuré.',
|
||||
created: 'Fournisseur créé.',
|
||||
updated: 'Fournisseur mis à jour.',
|
||||
deleted: 'Fournisseur supprimé.',
|
||||
deleteTitle: 'Supprimer le fournisseur',
|
||||
deleteMessage: 'Supprimer "{{name}}" ? Tous les comptes liés seront déconnectés.',
|
||||
form: {
|
||||
name: 'Nom d\'affichage',
|
||||
issuerUrl: 'URL de l\'émetteur',
|
||||
clientId: 'ID client',
|
||||
clientSecret: 'Secret client',
|
||||
scopes: 'Scopes',
|
||||
iconUrl: 'URL de l\'icône (optionnel)',
|
||||
enabled: 'Activé',
|
||||
autoCreate: 'Créer les utilisateurs automatiquement',
|
||||
autoCreateDesc: 'Crée automatiquement un compte local lors de la première connexion.',
|
||||
secretHint: 'laisser vide pour conserver',
|
||||
secretPlaceholder: 'nouveau secret',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
@@ -2169,6 +2242,28 @@ export default {
|
||||
loginSuccess: 'Connecté avec succès',
|
||||
loginFailed: 'Échec de connexion',
|
||||
enterCredentials: 'Entrez vos identifiants',
|
||||
enterEmail: 'Veuillez entrer votre adresse e-mail',
|
||||
oidcLoginFailed: 'Échec de la connexion OIDC',
|
||||
oidcErrors: {
|
||||
providerError: "Le fournisseur d'identité a renvoyé une erreur",
|
||||
missingParameters: 'Il manque des paramètres requis dans le callback OIDC',
|
||||
invalidState: "L'état OIDC est invalide ou a déjà été utilisé",
|
||||
stateExpired: 'La session OIDC a expiré — veuillez réessayer',
|
||||
providerNotFound: 'Fournisseur OIDC introuvable',
|
||||
discoveryFailed: 'Impossible de récupérer le document de découverte OIDC',
|
||||
invalidDiscovery: 'Le document de découverte OIDC est invalide',
|
||||
networkError: "Erreur réseau lors de l'échange de jeton OIDC",
|
||||
badResponse: "Réponse inattendue lors de l'échange de jeton OIDC",
|
||||
noIdToken: "Le fournisseur OIDC n'a pas renvoyé de jeton d'identité",
|
||||
validationFailed: 'La validation du jeton OIDC a échoué',
|
||||
nonceMismatch: 'Le nonce OIDC ne correspond pas — possible attaque par rejeu',
|
||||
missingSubClaim: 'Le jeton OIDC est dépourvu de la revendication sub',
|
||||
noLinkedAccount: 'Aucun compte local est lié à cette identité OIDC',
|
||||
accountInactive: 'Votre compte est inactif',
|
||||
userResolutionFailed: 'Impossible de résoudre votre compte',
|
||||
internalError: 'Une erreur interne est survenue lors de la connexion OIDC',
|
||||
tokenExchangeFailed: "L'échange de jeton OIDC a échoué",
|
||||
},
|
||||
forgotPasswordTitle: 'Mot de passe oublié',
|
||||
forgotPasswordMessage: 'Contactez votre administrateur pour réinitialiser votre accès.',
|
||||
forgotPasswordEmailMessage: 'Entrez votre email pour recevoir un nouveau mot de passe.',
|
||||
@@ -2183,6 +2278,33 @@ export default {
|
||||
resetStep3: 'Il vous donnera un mot de passe temporaire',
|
||||
resetStep4: 'Connectez-vous et changez-le dans les Paramètres',
|
||||
gotIt: 'Compris',
|
||||
twoFA: {
|
||||
title: 'Authentification à deux facteurs',
|
||||
subtitle: 'Votre compte est protégé par la 2FA. Saisissez le code de vérification ci-dessous.',
|
||||
methodAuthenticator: "Application d'authentification",
|
||||
methodEmail: 'Code par e-mail',
|
||||
methodBackup: 'Code de récupération',
|
||||
instructionsTotp: "Ouvrez votre application d'authentification et saisissez le code à 6 chiffres pour Bambuddy.",
|
||||
instructionsEmail: 'Un code à 6 chiffres a été envoyé à votre adresse e-mail. Il est valable 10 minutes.',
|
||||
instructionsEmailNotSent: 'Cliquez ci-dessous pour recevoir un code de vérification par e-mail.',
|
||||
instructionsBackup: "Saisissez l'un de vos codes de récupération à 8 caractères. Chaque code ne peut être utilisé qu'une seule fois.",
|
||||
sendCodeButton: 'Envoyer le code par e-mail',
|
||||
sendingCode: 'Envoi en cours...',
|
||||
resendCode: 'Renvoyer le code',
|
||||
codeLabel: 'Code de vérification',
|
||||
backupCodeLabel: 'Code de récupération',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Vérifier',
|
||||
verifyingButton: 'Vérification en cours...',
|
||||
backToLogin: '← Retour à la connexion',
|
||||
orContinueWith: 'ou continuer avec',
|
||||
signInWith: 'Se connecter avec {{provider}}',
|
||||
enterCode: 'Veuillez entrer le code de vérification',
|
||||
sendCodeFailed: 'Échec de l\'envoi du code de vérification',
|
||||
invalidCode: 'Code invalide. Veuillez réessayer.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
||||
@@ -102,6 +102,9 @@ export default {
|
||||
more: '+{{count}} altre',
|
||||
ascending: 'Crescente',
|
||||
descending: 'Decrescente',
|
||||
back: 'Indietro',
|
||||
copy: 'Copia',
|
||||
copied: 'Copiato!',
|
||||
printer: 'Stampante',
|
||||
remove: 'Rimuovi',
|
||||
type: 'Tipo',
|
||||
@@ -1324,6 +1327,8 @@ export default {
|
||||
backup: 'Backup',
|
||||
emailAuth: 'Autenticazione Email',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Autenticazione 2FA',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
ldap: {
|
||||
title: 'Autenticazione LDAP',
|
||||
@@ -2038,6 +2043,74 @@ export default {
|
||||
deleteUserAndItems: 'Elimina utente E i suoi elementi',
|
||||
deleteUserKeepItems: 'Elimina utente, mantieni elementi (diventeranno senza proprietario)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'App Authenticator (TOTP)',
|
||||
totpDesc: 'Usa un\'app come Google Authenticator, Aegis o Authy.',
|
||||
emailOtpTitle: 'OTP via e-mail',
|
||||
emailOtpDesc: 'Invia un codice monouso a {{email}} al momento del login.',
|
||||
emailOtpNoEmail: 'Aggiungi un indirizzo e-mail al tuo account per abilitare questo metodo.',
|
||||
addEmailFirst: 'Il tuo account non ha un indirizzo e-mail. Chiedi a un amministratore di aggiungerne uno.',
|
||||
setupTotp: 'Configura app Authenticator',
|
||||
setupAuthApp: 'Configura app Authenticator',
|
||||
setupInstructions: 'Scansiona il codice QR con la tua app authenticator, poi conferma con un codice.',
|
||||
manualEntry: 'Impossibile scansionare? Inserisci questo segreto manualmente:',
|
||||
scannedContinue: 'Codice scansionato — continua',
|
||||
enterCodeToConfirm: 'Inserisci il codice a 6 cifre dalla tua app authenticator per confermare.',
|
||||
activate: 'Attiva',
|
||||
disableTotp: 'Disabilita Authenticator',
|
||||
disableConfirmHint: 'Inserisci un codice TOTP valido o un codice di backup per disabilitare l\'authenticator.',
|
||||
totpDisabled: 'App Authenticator disabilitata.',
|
||||
emailOtpEnabled: 'OTP via e-mail abilitato.',
|
||||
emailOtpDisabled: 'OTP via e-mail disabilitato.',
|
||||
smtpRequired: 'Configura e testa prima le impostazioni SMTP.',
|
||||
invalidCode: 'Codice non valido. Riprova.',
|
||||
enableEmailOtp: 'Abilita OTP via e-mail',
|
||||
disableEmailOtp: 'Disabilita OTP via e-mail',
|
||||
emailSetupEnterCode: 'È stato inviato un codice di verifica al tuo indirizzo e-mail. Inseriscilo qui sotto per confermare che possiedi questa casella di posta.',
|
||||
verifyAndEnable: 'Verifica e abilita',
|
||||
emailDisablePasswordHint: 'Inserisci la password del tuo account per confermare la disabilitazione dell\'OTP via e-mail.',
|
||||
passwordPlaceholder: 'Inserisci la tua password',
|
||||
backupCodesTitle: 'Salva i tuoi codici di backup',
|
||||
backupCodesWarning: 'Conserva questi codici in un posto sicuro. Ogni codice può essere usato una sola volta.',
|
||||
backupCodesRemaining: '{{count}} codici di backup rimanenti',
|
||||
savedCodes: 'Codici salvati',
|
||||
regenBackup: 'Rigenera codici di backup',
|
||||
regenBackupHint: 'Inserisci il tuo codice TOTP corrente per generare 10 nuovi codici di backup.',
|
||||
newBackupCodes: 'Nuovi codici di backup',
|
||||
linkedAccounts: 'Account SSO collegati',
|
||||
linkedAccountsDesc: 'Questi provider di identità esterni sono collegati al tuo account.',
|
||||
oidcUnlinked: 'Account scollegato.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'Provider SSO / OIDC',
|
||||
desc: 'Configura provider OpenID Connect per il single sign-on.',
|
||||
addProvider: 'Aggiungi provider',
|
||||
newProvider: 'Nuovo provider',
|
||||
empty: 'Nessun provider OIDC configurato.',
|
||||
created: 'Provider creato.',
|
||||
updated: 'Provider aggiornato.',
|
||||
deleted: 'Provider eliminato.',
|
||||
deleteTitle: 'Elimina provider',
|
||||
deleteMessage: 'Eliminare "{{name}}"? Tutti gli account collegati verranno disconnessi.',
|
||||
form: {
|
||||
name: 'Nome visualizzato',
|
||||
issuerUrl: 'URL emittente',
|
||||
clientId: 'Client ID',
|
||||
clientSecret: 'Client secret',
|
||||
scopes: 'Scope',
|
||||
iconUrl: 'URL icona (opzionale)',
|
||||
enabled: 'Abilitato',
|
||||
autoCreate: 'Crea utenti automaticamente',
|
||||
autoCreateDesc: 'Crea automaticamente un account locale al primo accesso.',
|
||||
secretHint: 'lascia vuoto per mantenere',
|
||||
secretPlaceholder: 'nuovo segreto',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
@@ -2168,6 +2241,28 @@ export default {
|
||||
loginSuccess: 'Accesso riuscito',
|
||||
loginFailed: 'Accesso fallito',
|
||||
enterCredentials: 'Inserisci nome utente e password',
|
||||
enterEmail: 'Inserisci il tuo indirizzo e-mail',
|
||||
oidcLoginFailed: 'Accesso OIDC fallito',
|
||||
oidcErrors: {
|
||||
providerError: "Il provider di identità ha restituito un errore",
|
||||
missingParameters: 'Parametri obbligatori mancanti nel callback OIDC',
|
||||
invalidState: 'Lo stato OIDC non è valido o è già stato utilizzato',
|
||||
stateExpired: 'La sessione OIDC è scaduta — riprovare',
|
||||
providerNotFound: 'Provider OIDC non trovato',
|
||||
discoveryFailed: 'Impossibile recuperare il documento di discovery OIDC',
|
||||
invalidDiscovery: 'Il documento di discovery OIDC non è valido',
|
||||
networkError: "Errore di rete durante lo scambio di token OIDC",
|
||||
badResponse: "Risposta inattesa durante lo scambio di token OIDC",
|
||||
noIdToken: 'Il provider OIDC non ha restituito un ID token',
|
||||
validationFailed: 'La validazione del token OIDC non è riuscita',
|
||||
nonceMismatch: 'Il nonce OIDC non corrisponde — possibile attacco di replay',
|
||||
missingSubClaim: 'Il token OIDC è privo del claim sub',
|
||||
noLinkedAccount: 'Nessun account locale è collegato a questa identità OIDC',
|
||||
accountInactive: 'Il tuo account è inattivo',
|
||||
userResolutionFailed: 'Impossibile risolvere il tuo account',
|
||||
internalError: "Si è verificato un errore interno durante il login OIDC",
|
||||
tokenExchangeFailed: 'Lo scambio di token OIDC non è riuscito',
|
||||
},
|
||||
forgotPasswordTitle: 'Password dimenticata',
|
||||
forgotPasswordMessage: 'Se hai dimenticato la password, contatta il tuo amministratore di sistema per reimpostarla.',
|
||||
forgotPasswordEmailMessage: 'Inserisci il tuo indirizzo email e ti invieremo una nuova password.',
|
||||
@@ -2182,6 +2277,33 @@ export default {
|
||||
resetStep3: 'Possono impostare una nuova password temporanea',
|
||||
resetStep4: 'Accedi con la nuova password e cambiala in Impostazioni',
|
||||
gotIt: 'Capito',
|
||||
twoFA: {
|
||||
title: 'Autenticazione a due fattori',
|
||||
subtitle: 'Il tuo account è protetto da 2FA. Inserisci il codice di verifica qui sotto.',
|
||||
methodAuthenticator: 'App di autenticazione',
|
||||
methodEmail: 'Codice via e-mail',
|
||||
methodBackup: 'Codice di recupero',
|
||||
instructionsTotp: "Apri la tua app di autenticazione e inserisci il codice a 6 cifre per Bambuddy.",
|
||||
instructionsEmail: "Un codice a 6 cifre è stato inviato al tuo indirizzo e-mail. È valido per 10 minuti.",
|
||||
instructionsEmailNotSent: 'Clicca il pulsante qui sotto per ricevere un codice di verifica via e-mail.',
|
||||
instructionsBackup: 'Inserisci uno dei tuoi codici di recupero a 8 caratteri. Ogni codice può essere utilizzato una sola volta.',
|
||||
sendCodeButton: 'Invia codice via e-mail',
|
||||
sendingCode: 'Invio in corso...',
|
||||
resendCode: 'Invia nuovamente il codice',
|
||||
codeLabel: 'Codice di verifica',
|
||||
backupCodeLabel: 'Codice di recupero',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Verifica',
|
||||
verifyingButton: 'Verifica in corso...',
|
||||
backToLogin: '← Torna alla pagina di accesso',
|
||||
orContinueWith: 'oppure accedi con',
|
||||
signInWith: 'Accedi con {{provider}}',
|
||||
enterCode: 'Inserisci il codice di verifica',
|
||||
sendCodeFailed: 'Invio del codice di verifica non riuscito',
|
||||
invalidCode: 'Codice non valido. Riprova.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
||||
@@ -102,6 +102,9 @@ export default {
|
||||
more: 'もっと見る',
|
||||
ascending: '昇順',
|
||||
descending: '降順',
|
||||
back: '戻る',
|
||||
copy: 'コピー',
|
||||
copied: 'コピーしました!',
|
||||
printer: 'プリンター',
|
||||
remove: '削除',
|
||||
type: '種類',
|
||||
@@ -1324,6 +1327,8 @@ export default {
|
||||
backup: 'バックアップ',
|
||||
emailAuth: 'メール認証',
|
||||
ldap: 'LDAP',
|
||||
twoFa: '二段階認証',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
spoolbuddy: {
|
||||
infoTitle: 'SpoolBuddy デバイス',
|
||||
@@ -2077,6 +2082,74 @@ export default {
|
||||
deleteUserAndItems: 'ユーザーとそのアイテムを削除',
|
||||
deleteUserKeepItems: 'ユーザーを削除、アイテムは保持(オーナーなしになります)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: '認証アプリ (TOTP)',
|
||||
totpDesc: 'Google Authenticator、Aegis、Authyなどのアプリを使用します。',
|
||||
emailOtpTitle: 'メールOTP',
|
||||
emailOtpDesc: 'ログイン時に{{email}}にワンタイムコードを送信します。',
|
||||
emailOtpNoEmail: 'この方法を有効にするには、アカウントにメールアドレスを追加してください。',
|
||||
addEmailFirst: 'アカウントにメールアドレスがありません。管理者に追加を依頼してください。',
|
||||
setupTotp: '認証アプリを設定',
|
||||
setupAuthApp: '認証アプリを設定',
|
||||
setupInstructions: '認証アプリでQRコードをスキャンし、コードで確認してください。',
|
||||
manualEntry: 'スキャンできない場合は、このシークレットを手動で入力してください:',
|
||||
scannedContinue: 'コードをスキャンしました — 続ける',
|
||||
enterCodeToConfirm: '認証アプリの6桁のコードを入力して設定を確認してください。',
|
||||
activate: '有効化',
|
||||
disableTotp: '認証アプリを無効化',
|
||||
disableConfirmHint: '認証アプリを無効にするには、有効なTOTPコードまたはバックアップコードを入力してください。',
|
||||
totpDisabled: '認証アプリが無効化されました。',
|
||||
emailOtpEnabled: 'メールOTPが有効化されました。',
|
||||
emailOtpDisabled: 'メールOTPが無効化されました。',
|
||||
smtpRequired: '先にSMTP設定を構成してテストしてください。',
|
||||
invalidCode: '無効なコードです。もう一度お試しください。',
|
||||
enableEmailOtp: 'メールOTPを有効化',
|
||||
disableEmailOtp: 'メールOTPを無効化',
|
||||
emailSetupEnterCode: '確認コードがメールアドレスに送信されました。このメールボックスを所有していることを確認するために、以下に入力してください。',
|
||||
verifyAndEnable: '確認して有効化',
|
||||
emailDisablePasswordHint: 'メールOTPの無効化を確認するには、アカウントのパスワードを入力してください。',
|
||||
passwordPlaceholder: 'パスワードを入力してください',
|
||||
backupCodesTitle: 'バックアップコードを保存',
|
||||
backupCodesWarning: 'これらのコードを安全な場所に保存してください。各コードは一度しか使用できません。',
|
||||
backupCodesRemaining: 'バックアップコード残り{{count}}個',
|
||||
savedCodes: 'コードを保存しました',
|
||||
regenBackup: 'バックアップコードを再生成',
|
||||
regenBackupHint: '現在のTOTPコードを入力して10個の新しいバックアップコードを生成します。',
|
||||
newBackupCodes: '新しいバックアップコード',
|
||||
linkedAccounts: 'リンクされたSSOアカウント',
|
||||
linkedAccountsDesc: 'これらの外部IDプロバイダーがあなたのアカウントにリンクされています。',
|
||||
oidcUnlinked: 'アカウントのリンクを解除しました。',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'SSO / OIDCプロバイダー',
|
||||
desc: 'シングルサインオン用のOpenID Connectプロバイダーを設定します。',
|
||||
addProvider: 'プロバイダーを追加',
|
||||
newProvider: '新しいプロバイダー',
|
||||
empty: 'OIDCプロバイダーがまだ設定されていません。',
|
||||
created: 'プロバイダーが作成されました。',
|
||||
updated: 'プロバイダーが更新されました。',
|
||||
deleted: 'プロバイダーが削除されました。',
|
||||
deleteTitle: 'プロバイダーを削除',
|
||||
deleteMessage: '"{{name}}"を削除しますか?リンクされたすべてのユーザーアカウントが切断されます。',
|
||||
form: {
|
||||
name: '表示名',
|
||||
issuerUrl: '発行者URL',
|
||||
clientId: 'クライアントID',
|
||||
clientSecret: 'クライアントシークレット',
|
||||
scopes: 'スコープ',
|
||||
iconUrl: 'アイコンURL (任意)',
|
||||
enabled: '有効',
|
||||
autoCreate: 'ユーザーを自動作成',
|
||||
autoCreateDesc: '初回ログイン時にローカルアカウントを自動的に作成します。',
|
||||
secretHint: '空白のままで現在のものを維持',
|
||||
secretPlaceholder: '新しいシークレット',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
@@ -2207,6 +2280,28 @@ export default {
|
||||
loginSuccess: 'ログインしました',
|
||||
loginFailed: 'ログインに失敗しました',
|
||||
enterCredentials: 'ユーザー名とパスワードを入力してください',
|
||||
enterEmail: 'メールアドレスを入力してください',
|
||||
oidcLoginFailed: 'OIDCログインに失敗しました',
|
||||
oidcErrors: {
|
||||
providerError: 'IDプロバイダーがエラーを返しました',
|
||||
missingParameters: 'OIDCコールバックに必須パラメーターがありません',
|
||||
invalidState: 'OIDCの状態が無効か、すでに使用されています',
|
||||
stateExpired: 'OIDCログインセッションが期限切れです。もう一度お試しください',
|
||||
providerNotFound: 'OIDCプロバイダーが見つかりません',
|
||||
discoveryFailed: 'OIDCディスカバリードキュメントの取得に失敗しました',
|
||||
invalidDiscovery: 'OIDCディスカバリードキュメントが無効です',
|
||||
networkError: 'OIDCトークン交換中にネットワークエラーが発生しました',
|
||||
badResponse: 'OIDCトークン交換中に予期しない応答を受信しました',
|
||||
noIdToken: 'OIDCプロバイダーがIDトークンを返しませんでした',
|
||||
validationFailed: 'OIDCトークンの検証に失敗しました',
|
||||
nonceMismatch: 'OIDCノンスが一致しません。リプレイ攻撃の可能性があります',
|
||||
missingSubClaim: 'OIDCトークンにsubクレームがありません',
|
||||
noLinkedAccount: 'このOIDCアイデンティティに関連付けられたローカルアカウントがありません',
|
||||
accountInactive: 'あなたのアカウントは無効です',
|
||||
userResolutionFailed: 'アカウントを解決できませんでした',
|
||||
internalError: 'OIDCログイン中に内部エラーが発生しました',
|
||||
tokenExchangeFailed: 'OIDCトークン交換に失敗しました',
|
||||
},
|
||||
forgotPasswordTitle: 'パスワードを忘れた場合',
|
||||
forgotPasswordMessage: 'パスワードを忘れた場合は、システム管理者に連絡してリセットしてもらってください。',
|
||||
forgotPasswordEmailMessage: 'メールアドレスを入力すると、新しいパスワードを送信します。',
|
||||
@@ -2221,6 +2316,33 @@ export default {
|
||||
resetStep3: '管理者が新しい仮パスワードを設定',
|
||||
resetStep4: '新しいパスワードでログインし、設定で変更',
|
||||
gotIt: '了解',
|
||||
twoFA: {
|
||||
title: '二段階認証',
|
||||
subtitle: 'アカウントは二段階認証で保護されています。確認コードを入力してください。',
|
||||
methodAuthenticator: '認証アプリ',
|
||||
methodEmail: 'メール認証',
|
||||
methodBackup: 'バックアップコード',
|
||||
instructionsTotp: '認証アプリを開いて、Bambuddy用の6桁のコードを入力してください。',
|
||||
instructionsEmail: '6桁の確認コードをメールアドレスに送信しました。有効期限は10分です。',
|
||||
instructionsEmailNotSent: '下のボタンをクリックして、メールで確認コードを受け取ってください。',
|
||||
instructionsBackup: '8文字のバックアップコードをいずれか1つ入力してください。各コードは1回のみ使用可能です。',
|
||||
sendCodeButton: 'メールでコードを送信する',
|
||||
sendingCode: '送信中...',
|
||||
resendCode: 'コードを再送する',
|
||||
codeLabel: '確認コード',
|
||||
backupCodeLabel: 'バックアップコード',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: '確認する',
|
||||
verifyingButton: '確認中...',
|
||||
backToLogin: '← ログイン画面に戻る',
|
||||
orContinueWith: 'または以下でログイン',
|
||||
signInWith: '{{provider}}でログイン',
|
||||
enterCode: '確認コードを入力してください',
|
||||
sendCodeFailed: '確認コードの送信に失敗しました',
|
||||
invalidCode: '無効なコードです。もう一度お試しください。',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
||||
@@ -102,6 +102,9 @@ export default {
|
||||
more: '+{{count}} mais',
|
||||
ascending: 'Crescente',
|
||||
descending: 'Decrescente',
|
||||
back: 'Voltar',
|
||||
copy: 'Copiar',
|
||||
copied: 'Copiado!',
|
||||
printer: 'Impressora',
|
||||
remove: 'Remover',
|
||||
type: 'Tipo',
|
||||
@@ -1324,6 +1327,8 @@ export default {
|
||||
backup: 'Backup',
|
||||
emailAuth: 'Autenticação por Email',
|
||||
ldap: 'LDAP',
|
||||
twoFa: 'Autenticação 2FA',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
ldap: {
|
||||
title: 'Autenticação LDAP',
|
||||
@@ -2038,6 +2043,74 @@ export default {
|
||||
deleteUserAndItems: 'Excluir usuário E seus itens',
|
||||
deleteUserKeepItems: 'Excluir usuário, manter itens (ficarão sem dono)',
|
||||
ok: 'OK',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: 'App Autenticador (TOTP)',
|
||||
totpDesc: 'Use um app como Google Authenticator, Aegis ou Authy.',
|
||||
emailOtpTitle: 'OTP por e-mail',
|
||||
emailOtpDesc: 'Envie um código único para {{email}} ao fazer login.',
|
||||
emailOtpNoEmail: 'Adicione um endereço de e-mail à sua conta para ativar este método.',
|
||||
addEmailFirst: 'Sua conta não tem endereço de e-mail. Peça a um administrador para adicionar um.',
|
||||
setupTotp: 'Configurar App Autenticador',
|
||||
setupAuthApp: 'Configurar App Autenticador',
|
||||
setupInstructions: 'Escaneie o código QR com seu app autenticador e confirme com um código.',
|
||||
manualEntry: 'Não consegue escanear? Digite este segredo manualmente:',
|
||||
scannedContinue: 'Código escaneado — continuar',
|
||||
enterCodeToConfirm: 'Digite o código de 6 dígitos do seu app autenticador para confirmar.',
|
||||
activate: 'Ativar',
|
||||
disableTotp: 'Desativar Autenticador',
|
||||
disableConfirmHint: 'Digite um código TOTP válido ou um código de backup para desativar o autenticador.',
|
||||
totpDisabled: 'App autenticador desativado.',
|
||||
emailOtpEnabled: 'OTP por e-mail ativado.',
|
||||
emailOtpDisabled: 'OTP por e-mail desativado.',
|
||||
smtpRequired: 'Por favor, configure e teste as configurações SMTP primeiro.',
|
||||
invalidCode: 'Código inválido. Por favor, tente novamente.',
|
||||
enableEmailOtp: 'Ativar OTP por e-mail',
|
||||
disableEmailOtp: 'Desativar OTP por e-mail',
|
||||
emailSetupEnterCode: 'Um código de verificação foi enviado para o seu endereço de e-mail. Digite-o abaixo para confirmar que você possui esta caixa de entrada.',
|
||||
verifyAndEnable: 'Verificar e Ativar',
|
||||
emailDisablePasswordHint: 'Digite a senha da sua conta para confirmar a desativação do OTP por e-mail.',
|
||||
passwordPlaceholder: 'Digite sua senha',
|
||||
backupCodesTitle: 'Salve seus códigos de backup',
|
||||
backupCodesWarning: 'Guarde estes códigos em lugar seguro. Cada código só pode ser usado uma vez.',
|
||||
backupCodesRemaining: '{{count}} códigos de backup restantes',
|
||||
savedCodes: 'Códigos salvos',
|
||||
regenBackup: 'Regenerar códigos de backup',
|
||||
regenBackupHint: 'Digite seu código TOTP atual para gerar 10 novos códigos de backup.',
|
||||
newBackupCodes: 'Novos códigos de backup',
|
||||
linkedAccounts: 'Contas SSO vinculadas',
|
||||
linkedAccountsDesc: 'Estes provedores de identidade externos estão vinculados à sua conta.',
|
||||
oidcUnlinked: 'Conta desvinculada.',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'Provedores SSO / OIDC',
|
||||
desc: 'Configure provedores OpenID Connect para login único.',
|
||||
addProvider: 'Adicionar provedor',
|
||||
newProvider: 'Novo provedor',
|
||||
empty: 'Nenhum provedor OIDC configurado ainda.',
|
||||
created: 'Provedor criado.',
|
||||
updated: 'Provedor atualizado.',
|
||||
deleted: 'Provedor excluído.',
|
||||
deleteTitle: 'Excluir provedor',
|
||||
deleteMessage: 'Excluir "{{name}}"? Todas as contas vinculadas serão desconectadas.',
|
||||
form: {
|
||||
name: 'Nome de exibição',
|
||||
issuerUrl: 'URL do emissor',
|
||||
clientId: 'Client ID',
|
||||
clientSecret: 'Client secret',
|
||||
scopes: 'Escopos',
|
||||
iconUrl: 'URL do ícone (opcional)',
|
||||
enabled: 'Ativado',
|
||||
autoCreate: 'Criar usuários automaticamente',
|
||||
autoCreateDesc: 'Cria automaticamente uma conta local no primeiro login.',
|
||||
secretHint: 'deixe em branco para manter',
|
||||
secretPlaceholder: 'novo segredo',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
@@ -2168,6 +2241,28 @@ export default {
|
||||
loginSuccess: 'Login realizado com sucesso',
|
||||
loginFailed: 'Falha no login',
|
||||
enterCredentials: 'Por favor, insira nome de usuário e senha',
|
||||
enterEmail: 'Por favor, insira seu endereço de e-mail',
|
||||
oidcLoginFailed: 'Falha no login OIDC',
|
||||
oidcErrors: {
|
||||
providerError: 'O provedor de identidade retornou um erro',
|
||||
missingParameters: 'Parâmetros obrigatórios ausentes no callback OIDC',
|
||||
invalidState: 'Estado OIDC inválido ou já utilizado',
|
||||
stateExpired: 'Sessão OIDC expirada — tente novamente',
|
||||
providerNotFound: 'Provedor OIDC não encontrado',
|
||||
discoveryFailed: 'Falha ao obter o documento de descoberta OIDC',
|
||||
invalidDiscovery: 'Documento de descoberta OIDC inválido',
|
||||
networkError: 'Erro de rede durante a troca de token OIDC',
|
||||
badResponse: 'Resposta inesperada durante a troca de token OIDC',
|
||||
noIdToken: 'O provedor OIDC não retornou um token de ID',
|
||||
validationFailed: 'Falha na validação do token OIDC',
|
||||
nonceMismatch: 'Nonce OIDC não corresponde — possível ataque de replay',
|
||||
missingSubClaim: 'Token OIDC sem claim sub',
|
||||
noLinkedAccount: 'Nenhuma conta local vinculada a esta identidade OIDC',
|
||||
accountInactive: 'Sua conta está inativa',
|
||||
userResolutionFailed: 'Falha ao resolver sua conta',
|
||||
internalError: 'Erro interno durante o login OIDC',
|
||||
tokenExchangeFailed: 'Falha na troca de token OIDC',
|
||||
},
|
||||
forgotPasswordTitle: 'Esqueceu a Senha',
|
||||
forgotPasswordMessage: 'Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.',
|
||||
forgotPasswordEmailMessage: 'Digite seu endereço de email e enviaremos uma nova senha.',
|
||||
@@ -2182,6 +2277,47 @@ export default {
|
||||
resetStep3: 'Eles podem definir uma nova senha temporária para você',
|
||||
resetStep4: 'Faça login com a nova senha e altere-a nas Configurações',
|
||||
gotIt: 'Entendi',
|
||||
resetPassword: {
|
||||
title: 'Definir nova senha',
|
||||
subtitle: 'Digite e confirme sua nova senha abaixo.',
|
||||
newPassword: 'Nova senha',
|
||||
newPasswordPlaceholder: 'Pelo menos 8 caracteres',
|
||||
confirmPassword: 'Confirmar senha',
|
||||
confirmPasswordPlaceholder: 'Repetir nova senha',
|
||||
saving: 'Salvando\u2026',
|
||||
submit: 'Definir nova senha',
|
||||
backToLogin: 'Voltar para o login',
|
||||
passwordsDoNotMatch: 'As senhas não coincidem',
|
||||
passwordTooShort: 'A senha deve ter pelo menos 8 caracteres',
|
||||
resetFailed: 'Falha ao redefinir senha. O link pode ter expirado.',
|
||||
},
|
||||
twoFA: {
|
||||
title: 'Autenticação em dois fatores',
|
||||
subtitle: 'Sua conta está protegida com 2FA. Insira o código de verificação abaixo.',
|
||||
methodAuthenticator: 'Aplicativo autenticador',
|
||||
methodEmail: 'Código por e-mail',
|
||||
methodBackup: 'Código de recuperação',
|
||||
instructionsTotp: 'Abra seu aplicativo autenticador e insira o código de 6 dígitos gerado para o Bambuddy.',
|
||||
instructionsEmail: 'Um código de 6 dígitos foi enviado para o seu e-mail. Ele é válido por 10 minutos.',
|
||||
instructionsEmailNotSent: 'Clique no botão abaixo para receber um código de verificação por e-mail.',
|
||||
instructionsBackup: 'Insira um dos seus códigos de recuperação de 8 caracteres. Cada código só pode ser utilizado uma vez.',
|
||||
sendCodeButton: 'Enviar código por e-mail',
|
||||
sendingCode: 'Enviando...',
|
||||
resendCode: 'Reenviar código',
|
||||
codeLabel: 'Código de verificação',
|
||||
backupCodeLabel: 'Código de recuperação',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: 'Verificar',
|
||||
verifyingButton: 'Verificando...',
|
||||
backToLogin: '← Voltar para o login',
|
||||
orContinueWith: 'ou entrar com',
|
||||
signInWith: 'Entrar com {{provider}}',
|
||||
enterCode: 'Por favor, insira o código de verificação',
|
||||
sendCodeFailed: 'Falha ao enviar o código de verificação',
|
||||
invalidCode: 'Código inválido. Por favor, tente novamente.',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
||||
@@ -102,6 +102,9 @@ export default {
|
||||
more: '还有 {{count}} 个',
|
||||
ascending: '升序',
|
||||
descending: '降序',
|
||||
back: '返回',
|
||||
copy: '复制',
|
||||
copied: '已复制!',
|
||||
printer: '打印机',
|
||||
remove: '移除',
|
||||
type: '类型',
|
||||
@@ -1324,6 +1327,8 @@ export default {
|
||||
backup: '备份',
|
||||
emailAuth: '邮箱认证',
|
||||
ldap: 'LDAP',
|
||||
twoFa: '双因素认证',
|
||||
oidc: 'SSO / OIDC',
|
||||
},
|
||||
ldap: {
|
||||
title: 'LDAP 认证',
|
||||
@@ -2038,6 +2043,74 @@ export default {
|
||||
deleteUserAndItems: '删除用户及其所有项目',
|
||||
deleteUserKeepItems: '删除用户,保留项目(将变为无主项目)',
|
||||
ok: '确定',
|
||||
|
||||
// 2FA settings
|
||||
twoFa: {
|
||||
totpTitle: '身份验证器应用 (TOTP)',
|
||||
totpDesc: '使用 Google Authenticator、Aegis 或 Authy 等应用。',
|
||||
emailOtpTitle: '邮件 OTP',
|
||||
emailOtpDesc: '登录时向 {{email}} 发送一次性验证码。',
|
||||
emailOtpNoEmail: '请先为账户添加邮箱地址以启用此方式。',
|
||||
addEmailFirst: '您的账户没有邮箱地址,请联系管理员添加。',
|
||||
setupTotp: '设置身份验证器应用',
|
||||
setupAuthApp: '设置身份验证器应用',
|
||||
setupInstructions: '使用身份验证器应用扫描二维码,然后输入验证码确认。',
|
||||
manualEntry: '无法扫描?请手动输入此密钥:',
|
||||
scannedContinue: '已扫描 — 继续',
|
||||
enterCodeToConfirm: '请输入身份验证器应用中的6位验证码以确认设置。',
|
||||
activate: '激活',
|
||||
disableTotp: '停用身份验证器',
|
||||
disableConfirmHint: '请输入有效的 TOTP 码或备用码来停用身份验证器。',
|
||||
totpDisabled: '身份验证器应用已停用。',
|
||||
emailOtpEnabled: '邮件 OTP 已启用。',
|
||||
emailOtpDisabled: '邮件 OTP 已停用。',
|
||||
smtpRequired: '请先配置并测试SMTP设置。',
|
||||
invalidCode: '无效验证码,请重试。',
|
||||
enableEmailOtp: '启用邮件 OTP',
|
||||
disableEmailOtp: '停用邮件 OTP',
|
||||
emailSetupEnterCode: '验证码已发送至您的邮箱地址。请在下方输入以确认您拥有此邮箱。',
|
||||
verifyAndEnable: '验证并启用',
|
||||
emailDisablePasswordHint: '请输入您的账户密码以确认停用邮件 OTP。',
|
||||
passwordPlaceholder: '输入您的密码',
|
||||
backupCodesTitle: '保存备用码',
|
||||
backupCodesWarning: '请将这些码保存在安全的地方。每个码只能使用一次,且不会再次显示。',
|
||||
backupCodesRemaining: '剩余 {{count}} 个备用码',
|
||||
savedCodes: '已保存',
|
||||
regenBackup: '重新生成备用码',
|
||||
regenBackupHint: '输入当前 TOTP 码以生成 10 个新备用码,所有现有备用码将失效。',
|
||||
newBackupCodes: '新备用码',
|
||||
linkedAccounts: '已关联的 SSO 账户',
|
||||
linkedAccountsDesc: '以下外部身份提供商已与您的账户关联。',
|
||||
oidcUnlinked: '账户已解除关联。',
|
||||
},
|
||||
|
||||
// OIDC provider settings
|
||||
oidc: {
|
||||
title: 'SSO / OIDC 提供商',
|
||||
desc: '配置 OpenID Connect 提供商以实现单点登录。',
|
||||
addProvider: '添加提供商',
|
||||
newProvider: '新提供商',
|
||||
empty: '尚未配置 OIDC 提供商。',
|
||||
created: '提供商已创建。',
|
||||
updated: '提供商已更新。',
|
||||
deleted: '提供商已删除。',
|
||||
deleteTitle: '删除提供商',
|
||||
deleteMessage: '删除"{{name}}"?所有关联账户将断开连接。',
|
||||
form: {
|
||||
name: '显示名称',
|
||||
issuerUrl: '颁发者 URL',
|
||||
clientId: '客户端 ID',
|
||||
clientSecret: '客户端密钥',
|
||||
scopes: '作用域',
|
||||
iconUrl: '图标 URL(可选)',
|
||||
enabled: '已启用',
|
||||
autoCreate: '自动创建用户',
|
||||
autoCreateDesc: '首次登录时自动创建本地账户。',
|
||||
secretHint: '留空以保留当前',
|
||||
secretPlaceholder: '新密钥',
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Notifications (for push notifications)
|
||||
@@ -2168,6 +2241,28 @@ export default {
|
||||
loginSuccess: '登录成功',
|
||||
loginFailed: '登录失败',
|
||||
enterCredentials: '请输入用户名和密码',
|
||||
enterEmail: '请输入您的电子邮件地址',
|
||||
oidcLoginFailed: 'OIDC 登录失败',
|
||||
oidcErrors: {
|
||||
providerError: '身份提供商返回了一个错误',
|
||||
missingParameters: 'OIDC 回调缺少必要参数',
|
||||
invalidState: 'OIDC 状态无效或已被使用',
|
||||
stateExpired: 'OIDC 登录会话已过期,请重试',
|
||||
providerNotFound: '未找到 OIDC 提供商',
|
||||
discoveryFailed: '无法获取 OIDC 发现文档',
|
||||
invalidDiscovery: 'OIDC 发现文档无效',
|
||||
networkError: 'OIDC 令牌交换时出现网络错误',
|
||||
badResponse: 'OIDC 令牌交换时收到意外响应',
|
||||
noIdToken: 'OIDC 提供商未返回 ID 令牌',
|
||||
validationFailed: 'OIDC 令牌验证失败',
|
||||
nonceMismatch: 'OIDC nonce 不匹配,可能存在重放攻击',
|
||||
missingSubClaim: 'OIDC 令牌缺少 sub 声明',
|
||||
noLinkedAccount: '没有与此 OIDC 身份关联的本地帐户',
|
||||
accountInactive: '您的帐户已被停用',
|
||||
userResolutionFailed: '无法解析您的帐户',
|
||||
internalError: 'OIDC 登录过程中发生内部错误',
|
||||
tokenExchangeFailed: 'OIDC 令牌交换失败',
|
||||
},
|
||||
forgotPasswordTitle: '忘记密码',
|
||||
forgotPasswordMessage: '如果您忘记了密码,请联系系统管理员进行重置。',
|
||||
forgotPasswordEmailMessage: '输入您的邮箱地址,我们将向您发送新密码。',
|
||||
@@ -2182,6 +2277,33 @@ export default {
|
||||
resetStep3: '他们可以为您设置一个临时密码',
|
||||
resetStep4: '使用新密码登录并在设置中修改密码',
|
||||
gotIt: '知道了',
|
||||
twoFA: {
|
||||
title: '两步验证',
|
||||
subtitle: '您的账户已启用两步验证。请在下方输入验证码。',
|
||||
methodAuthenticator: '身份验证器应用',
|
||||
methodEmail: '邮箱验证码',
|
||||
methodBackup: '备用恢复码',
|
||||
instructionsTotp: '请打开您的身份验证器应用,输入 Bambuddy 的 6 位验证码。',
|
||||
instructionsEmail: '6 位验证码已发送至您的邮箱,有效期为 10 分钟。',
|
||||
instructionsEmailNotSent: '点击下方按钮,通过邮件获取验证码。',
|
||||
instructionsBackup: '请输入您的一个 8 位备用恢复码。每个恢复码只能使用一次。',
|
||||
sendCodeButton: '发送邮箱验证码',
|
||||
sendingCode: '发送中...',
|
||||
resendCode: '重新发送验证码',
|
||||
codeLabel: '验证码',
|
||||
backupCodeLabel: '备用恢复码',
|
||||
codePlaceholder: '000000',
|
||||
backupCodePlaceholder: 'XXXXXXXX',
|
||||
verifyButton: '验证',
|
||||
verifyingButton: '验证中...',
|
||||
backToLogin: '← 返回登录页面',
|
||||
orContinueWith: '或通过以下方式登录',
|
||||
signInWith: '使用 {{provider}} 登录',
|
||||
enterCode: '请输入验证码',
|
||||
sendCodeFailed: '验证码发送失败',
|
||||
invalidCode: '无效验证码,请重试。',
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
// Setup page
|
||||
|
||||
@@ -1,37 +1,153 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { X, Mail } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { X, Mail, Shield, Smartphone, Key } from 'lucide-react';
|
||||
import { api, type LoginResponse } from '../api/client';
|
||||
import { Card, CardHeader, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
type LoginStep = 'credentials' | '2fa' | 'reset-password';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { login } = useAuth();
|
||||
const { login, loginWithToken } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const { mode } = useTheme();
|
||||
|
||||
// Credentials step state
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
|
||||
// 2FA step state
|
||||
const [step, setStep] = useState<LoginStep>('credentials');
|
||||
const [preAuthToken, setPreAuthToken] = useState('');
|
||||
const [twoFAMethods, setTwoFAMethods] = useState<string[]>([]);
|
||||
const [twoFAMethod, setTwoFAMethod] = useState<'totp' | 'email' | 'backup'>('totp');
|
||||
const [twoFACode, setTwoFACode] = useState('');
|
||||
const [emailOTPSent, setEmailOTPSent] = useState(false);
|
||||
const twoFAInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// H-6: Password reset step state
|
||||
const [resetToken, setResetToken] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
// Check if advanced auth is enabled
|
||||
const { data: advancedAuthStatus } = useQuery({
|
||||
queryKey: ['advancedAuthStatus'],
|
||||
queryFn: () => api.getAdvancedAuthStatus(),
|
||||
});
|
||||
|
||||
// Fetch enabled OIDC providers for login buttons
|
||||
const { data: oidcProviders } = useQuery({
|
||||
queryKey: ['oidcProviders'],
|
||||
queryFn: () => api.getOIDCProviders(),
|
||||
});
|
||||
|
||||
// M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.
|
||||
// Fragments are never sent to the server so the token never appears in access-logs
|
||||
// or Referer headers — mirrors the H-4 treatment of the OIDC token.
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const token = hash.startsWith('#reset_token=') ? hash.slice('#reset_token='.length) : null;
|
||||
if (token) {
|
||||
setResetToken(token);
|
||||
setStep('reset-password');
|
||||
// Clear the fragment from the URL so it can't be bookmarked or re-triggered.
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle OIDC callback: if #oidc_token=... is present in the fragment, exchange it.
|
||||
// H-4: Read from the URL fragment (#) — fragments are never sent to the server
|
||||
// so the exchange token stays out of access logs and Referer headers.
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
|
||||
const oidcError = searchParams.get('oidc_error');
|
||||
|
||||
if (oidcError) {
|
||||
// L-3: Whitelist known OIDC error codes so provider-controlled text is never
|
||||
// shown verbatim. Any unknown code falls back to a generic message.
|
||||
const KNOWN_OIDC_ERRORS: Record<string, string> = {
|
||||
oidc_provider_error: t('login.oidcErrors.providerError'),
|
||||
missing_parameters: t('login.oidcErrors.missingParameters'),
|
||||
invalid_state: t('login.oidcErrors.invalidState'),
|
||||
state_expired: t('login.oidcErrors.stateExpired'),
|
||||
provider_not_found: t('login.oidcErrors.providerNotFound'),
|
||||
discovery_failed: t('login.oidcErrors.discoveryFailed'),
|
||||
invalid_discovery_document: t('login.oidcErrors.invalidDiscovery'),
|
||||
token_exchange_network_error: t('login.oidcErrors.networkError'),
|
||||
token_exchange_bad_response: t('login.oidcErrors.badResponse'),
|
||||
no_id_token: t('login.oidcErrors.noIdToken'),
|
||||
token_validation_failed: t('login.oidcErrors.validationFailed'),
|
||||
nonce_mismatch: t('login.oidcErrors.nonceMismatch'),
|
||||
missing_sub_claim: t('login.oidcErrors.missingSubClaim'),
|
||||
no_linked_account: t('login.oidcErrors.noLinkedAccount'),
|
||||
account_inactive: t('login.oidcErrors.accountInactive'),
|
||||
user_resolution_failed: t('login.oidcErrors.userResolutionFailed'),
|
||||
internal_error: t('login.oidcErrors.internalError'),
|
||||
};
|
||||
// Dynamic codes like "token_exchange_<provider_code>" → generic message
|
||||
const errorMsg = KNOWN_OIDC_ERRORS[oidcError]
|
||||
?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed'));
|
||||
showToast(errorMsg, 'error');
|
||||
// Remove query params from URL cleanly
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (oidcToken) {
|
||||
api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
|
||||
if (resp.requires_2fa && resp.pre_auth_token) {
|
||||
// OIDC user has 2FA enabled — redirect to 2FA step
|
||||
setPreAuthToken(resp.pre_auth_token);
|
||||
const methods = resp.two_fa_methods ?? [];
|
||||
setTwoFAMethods(methods);
|
||||
if (methods.includes('totp')) setTwoFAMethod('totp');
|
||||
else if (methods.includes('email')) setTwoFAMethod('email');
|
||||
else setTwoFAMethod('backup');
|
||||
setStep('2fa');
|
||||
// Remove oidc_token from URL so page refresh doesn't re-trigger exchange
|
||||
navigate('/login', { replace: true });
|
||||
} else if (resp.access_token && resp.user) {
|
||||
loginWithToken(resp.access_token, resp.user);
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}).catch((err: Error) => {
|
||||
showToast(err.message || t('login.oidcLoginFailed'), 'error');
|
||||
navigate('/login', { replace: true });
|
||||
});
|
||||
}
|
||||
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// --- Step 1: Credentials login ---
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: () => login(username, password),
|
||||
onSuccess: () => {
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
onSuccess: (resp: LoginResponse) => {
|
||||
if (resp.requires_2fa && resp.pre_auth_token) {
|
||||
// 2FA required — switch to verification step
|
||||
setPreAuthToken(resp.pre_auth_token);
|
||||
const methods = resp.two_fa_methods ?? [];
|
||||
setTwoFAMethods(methods);
|
||||
// Pick a sensible default method
|
||||
if (methods.includes('totp')) setTwoFAMethod('totp');
|
||||
else if (methods.includes('email')) setTwoFAMethod('email');
|
||||
else setTwoFAMethod('backup');
|
||||
setStep('2fa');
|
||||
} else if (resp.access_token && resp.user) {
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.loginFailed'), 'error');
|
||||
@@ -50,6 +166,62 @@ export function LoginPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// H-6: Mutation to set a new password using the reset token from the email link
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: () => api.forgotPasswordConfirm(resetToken, newPassword),
|
||||
onSuccess: (data) => {
|
||||
showToast(data.message, 'success');
|
||||
setStep('credentials');
|
||||
setResetToken('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.resetPassword.resetFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// --- Step 2: 2FA verification ---
|
||||
const sendEmailOTPMutation = useMutation({
|
||||
mutationFn: () => api.sendEmailOTP(preAuthToken),
|
||||
onSuccess: (data: { message: string; pre_auth_token?: string }) => {
|
||||
setEmailOTPSent(true);
|
||||
// Backend issues a fresh pre-auth token after consuming the original one
|
||||
if (data.pre_auth_token) setPreAuthToken(data.pre_auth_token);
|
||||
showToast(data.message, 'success');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.twoFA.sendCodeFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const verify2FAMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }),
|
||||
onSuccess: (resp: LoginResponse) => {
|
||||
if (resp.access_token && resp.user) {
|
||||
loginWithToken(resp.access_token, resp.user);
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.twoFA.invalidCode'), 'error');
|
||||
setTwoFACode('');
|
||||
},
|
||||
});
|
||||
|
||||
// OIDC login
|
||||
const oidcLoginMutation = useMutation({
|
||||
mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.auth_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
showToast(error.message || t('login.oidcLoginFailed'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
@@ -59,15 +231,285 @@ export function LoginPage() {
|
||||
loginMutation.mutate();
|
||||
};
|
||||
|
||||
const handle2FASubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!twoFACode.trim()) {
|
||||
showToast(t('login.twoFA.enterCode'), 'error');
|
||||
return;
|
||||
}
|
||||
verify2FAMutation.mutate();
|
||||
};
|
||||
|
||||
const handleForgotPassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!forgotEmail) {
|
||||
showToast('Please enter your email address', 'error');
|
||||
showToast(t('login.enterEmail'), 'error');
|
||||
return;
|
||||
}
|
||||
forgotPasswordMutation.mutate(forgotEmail);
|
||||
};
|
||||
|
||||
const handleMethodChange = (method: 'totp' | 'email' | 'backup') => {
|
||||
setTwoFAMethod(method);
|
||||
setTwoFACode('');
|
||||
setEmailOTPSent(false);
|
||||
// Re-focus the code input after method switch (autoFocus only fires on mount)
|
||||
setTimeout(() => twoFAInputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// ---- Render: password-reset step (H-6) ----
|
||||
if (step === 'reset-password') {
|
||||
const handleResetSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
showToast(t('login.resetPassword.passwordsDoNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
showToast(t('login.resetPassword.passwordTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
resetPasswordMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
|
||||
<Key className="w-7 h-7 text-bambu-green" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white">{t('login.resetPassword.title')}</h2>
|
||||
<p className="mt-2 text-sm text-bambu-gray">{t('login.resetPassword.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleResetSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="new-password" className="block text-sm font-medium text-white mb-2">
|
||||
{t('login.resetPassword.newPassword')}
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
required
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('login.resetPassword.newPasswordPlaceholder')}
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
|
||||
{t('login.resetPassword.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('login.resetPassword.confirmPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={resetPasswordMutation.isPending || !newPassword || !confirmPassword}
|
||||
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{resetPasswordMutation.isPending ? t('login.resetPassword.saving') : t('login.resetPassword.submit')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials');
|
||||
setResetToken('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.resetPassword.backToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Render: 2FA step ----
|
||||
if (step === '2fa') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-14 h-14 rounded-full bg-bambu-green/20 flex items-center justify-center">
|
||||
<Shield className="w-7 h-7 text-bambu-green" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white">{t('login.twoFA.title')}</h2>
|
||||
<p className="mt-2 text-sm text-bambu-gray">{t('login.twoFA.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Method selector — only show if multiple methods available */}
|
||||
{twoFAMethods.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{twoFAMethods.includes('totp') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMethodChange('totp')}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
||||
twoFAMethod === 'totp'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="w-4 h-4" />
|
||||
{t('login.twoFA.methodAuthenticator')}
|
||||
</button>
|
||||
)}
|
||||
{twoFAMethods.includes('email') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMethodChange('email')}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
||||
twoFAMethod === 'email'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
{t('login.twoFA.methodEmail')}
|
||||
</button>
|
||||
)}
|
||||
{twoFAMethods.includes('backup') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMethodChange('backup')}
|
||||
className={`flex-1 flex flex-col items-center gap-1 py-2 px-3 rounded-lg border text-xs font-medium transition-colors ${
|
||||
twoFAMethod === 'backup'
|
||||
? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
|
||||
: 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-green/50'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
{t('login.twoFA.methodBackup')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handle2FASubmit} className="space-y-4">
|
||||
{/* Method-specific instructions */}
|
||||
{twoFAMethod === 'totp' && (
|
||||
<p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsTotp')}</p>
|
||||
)}
|
||||
{twoFAMethod === 'email' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{emailOTPSent
|
||||
? t('login.twoFA.instructionsEmail')
|
||||
: t('login.twoFA.instructionsEmailNotSent')}
|
||||
</p>
|
||||
{!emailOTPSent && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => sendEmailOTPMutation.mutate()}
|
||||
disabled={sendEmailOTPMutation.isPending}
|
||||
>
|
||||
{sendEmailOTPMutation.isPending
|
||||
? t('login.twoFA.sendingCode')
|
||||
: t('login.twoFA.sendCodeButton')}
|
||||
</Button>
|
||||
)}
|
||||
{emailOTPSent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEmailOTPSent(false); sendEmailOTPMutation.mutate(); }}
|
||||
className="text-xs text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.twoFA.resendCode')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{twoFAMethod === 'backup' && (
|
||||
<p className="text-sm text-bambu-gray">{t('login.twoFA.instructionsBackup')}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="twofa-code" className="block text-sm font-medium text-white mb-2">
|
||||
{twoFAMethod === 'backup'
|
||||
? t('login.twoFA.backupCodeLabel')
|
||||
: t('login.twoFA.codeLabel')}
|
||||
</label>
|
||||
<input
|
||||
ref={twoFAInputRef}
|
||||
id="twofa-code"
|
||||
type="text"
|
||||
inputMode={twoFAMethod === 'backup' ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
value={twoFACode}
|
||||
onChange={(e) => setTwoFACode(e.target.value.trim())}
|
||||
disabled={twoFAMethod === 'email' && !emailOTPSent}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-center tracking-widest text-xl font-mono focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-40"
|
||||
placeholder={twoFAMethod === 'backup'
|
||||
? t('login.twoFA.backupCodePlaceholder')
|
||||
: t('login.twoFA.codePlaceholder')}
|
||||
maxLength={twoFAMethod === 'backup' ? 8 : 6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
verify2FAMutation.isPending ||
|
||||
!twoFACode.trim() ||
|
||||
(twoFAMethod === 'email' && !emailOTPSent)
|
||||
}
|
||||
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{verify2FAMutation.isPending
|
||||
? t('login.twoFA.verifyingButton')
|
||||
: t('login.twoFA.verifyButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials');
|
||||
setPreAuthToken('');
|
||||
setTwoFACode('');
|
||||
setEmailOTPSent(false);
|
||||
}}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.twoFA.backToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Render: credentials step ----
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
|
||||
@@ -92,7 +534,7 @@ export function LoginPage() {
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
|
||||
{advancedAuthStatus?.advanced_auth_enabled
|
||||
? t('login.usernameOrEmail') || 'Username or Email'
|
||||
? t('login.usernameOrEmail')
|
||||
: t('login.username')}
|
||||
</label>
|
||||
<input
|
||||
@@ -103,7 +545,7 @@ export function LoginPage() {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={advancedAuthStatus?.advanced_auth_enabled
|
||||
? t('login.usernameOrEmailPlaceholder') || 'Enter your username or email'
|
||||
? t('login.usernameOrEmailPlaceholder')
|
||||
: t('login.usernamePlaceholder')}
|
||||
autoComplete="username"
|
||||
/>
|
||||
@@ -111,7 +553,7 @@ export function LoginPage() {
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-white mb-2">
|
||||
{t('login.password')}
|
||||
{t('login.password') || 'Password'}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -136,18 +578,49 @@ export function LoginPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{advancedAuthStatus?.advanced_auth_enabled && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
|
||||
>
|
||||
{t('login.forgotPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* OIDC provider buttons */}
|
||||
{oidcProviders && oidcProviders.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-bambu-dark-tertiary" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-bambu-dark-secondary text-bambu-gray">{t('login.twoFA.orContinueWith')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{oidcProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => oidcLoginMutation.mutate(provider.id)}
|
||||
disabled={oidcLoginMutation.isPending}
|
||||
className="w-full flex items-center justify-center gap-3 py-3 px-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green/50 rounded-lg text-white font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{provider.icon_url ? (
|
||||
<img src={provider.icon_url} alt="" className="w-5 h-5 object-contain" />
|
||||
) : (
|
||||
<Shield className="w-5 h-5 text-bambu-green" />
|
||||
)}
|
||||
{t('login.twoFA.signInWith', { provider: provider.name })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Forgot Password Modal */}
|
||||
@@ -182,12 +655,12 @@ export function LoginPage() {
|
||||
{advancedAuthStatus?.advanced_auth_enabled ? (
|
||||
<form onSubmit={handleForgotPassword} className="space-y-4">
|
||||
<p className="text-bambu-gray text-sm">
|
||||
{t('login.forgotPasswordEmailMessage') || 'Enter your email address and we\'ll send you a new password.'}
|
||||
{t('login.forgotPasswordEmailMessage')}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
|
||||
{t('login.emailAddress') || 'Email Address'}
|
||||
{t('login.emailAddress')}
|
||||
</label>
|
||||
<input
|
||||
id="forgot-email"
|
||||
@@ -196,7 +669,7 @@ export function LoginPage() {
|
||||
value={forgotEmail}
|
||||
onChange={(e) => setForgotEmail(e.target.value)}
|
||||
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
|
||||
placeholder={t('login.emailPlaceholder') || 'your.email@example.com'}
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +683,7 @@ export function LoginPage() {
|
||||
setForgotEmail('');
|
||||
}}
|
||||
>
|
||||
{t('login.cancel') || 'Cancel'}
|
||||
{t('login.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -218,8 +691,8 @@ export function LoginPage() {
|
||||
disabled={forgotPasswordMutation.isPending}
|
||||
>
|
||||
{forgotPasswordMutation.isPending
|
||||
? (t('login.sending') || 'Sending...')
|
||||
: (t('login.sendResetEmail') || 'Send Reset Email')}
|
||||
? t('login.sending')
|
||||
: t('login.sendResetEmail')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -28,6 +28,8 @@ import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
|
||||
import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
|
||||
import { EmailSettings } from '../components/EmailSettings';
|
||||
import { LDAPSettings } from '../components/LDAPSettings';
|
||||
import { TwoFactorSettings } from '../components/TwoFactorSettings';
|
||||
import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
|
||||
import { APIBrowser } from '../components/APIBrowser';
|
||||
import { Toggle } from '../components/Toggle';
|
||||
import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
|
||||
@@ -40,7 +42,7 @@ import { Palette } from 'lucide-react';
|
||||
|
||||
const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
|
||||
type TabType = typeof validTabs[number];
|
||||
type UsersSubTab = 'users' | 'email' | 'ldap';
|
||||
type UsersSubTab = 'users' | 'email' | 'ldap' | 'twofa' | 'oidc';
|
||||
|
||||
const STORAGE_CATEGORY_COLORS: Record<string, string> = {
|
||||
database: 'bg-blue-600',
|
||||
@@ -80,7 +82,7 @@ export function SettingsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { authEnabled, user, refreshAuth, hasPermission } = useAuth();
|
||||
const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = useAuth();
|
||||
const {
|
||||
mode,
|
||||
darkStyle, darkBackground, darkAccent,
|
||||
@@ -4403,6 +4405,30 @@ export function SettingsPage() {
|
||||
<span className="w-2 h-2 rounded-full bg-green-400" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUsersSubTab('twofa')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
usersSubTab === 'twofa'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{t('settings.tabs.twoFa')}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setUsersSubTab('oidc')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
|
||||
usersSubTab === 'oidc'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{t('settings.tabs.oidc')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Users Sub-tab */}
|
||||
@@ -4729,6 +4755,18 @@ export function SettingsPage() {
|
||||
<LDAPSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersSubTab === 'twofa' && (
|
||||
<div className="max-w-2xl">
|
||||
<TwoFactorSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersSubTab === 'oidc' && isAdmin && (
|
||||
<div className="max-w-3xl">
|
||||
<OIDCProviderSettings />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user