mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1126)
feat(oidc): add Azure Entra ID support with configurable email claim resolution Adds two new OIDC provider fields: email_claim and require_email_verified.
This commit is contained in:
@@ -33,6 +33,8 @@ beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
sessionStorageMock.clear();
|
||||
vi.mocked(localStorage.setItem).mockClear();
|
||||
vi.mocked(localStorage.removeItem).mockClear();
|
||||
setAuthToken(null);
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
@@ -50,6 +52,60 @@ describe('Auth Token Management', () => {
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(getAuthToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("setAuthToken('persistent') writes to both sessionStorage and localStorage", () => {
|
||||
setAuthToken('persist-token', 'persistent');
|
||||
expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'persist-token');
|
||||
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'persist-token');
|
||||
expect(getAuthToken()).toBe('persist-token');
|
||||
});
|
||||
|
||||
it("setAuthToken('session') writes only to sessionStorage, not localStorage", () => {
|
||||
setAuthToken('session-token', 'session');
|
||||
expect(sessionStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'session-token');
|
||||
expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
|
||||
});
|
||||
|
||||
it('setAuthToken(null) removes from both storages regardless of previous persistence', () => {
|
||||
setAuthToken('some-token', 'persistent');
|
||||
vi.mocked(localStorage.setItem).mockClear();
|
||||
setAuthToken(null);
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(vi.mocked(localStorage.removeItem)).toHaveBeenCalledWith('auth_token');
|
||||
expect(getAuthToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('setAuthToken keeps in-memory token when sessionStorage throws', () => {
|
||||
sessionStorageMock.setItem.mockImplementationOnce(() => {
|
||||
throw new DOMException('QuotaExceededError');
|
||||
});
|
||||
// Should not throw even when storage is unavailable
|
||||
expect(() => setAuthToken('fallback-token')).not.toThrow();
|
||||
// In-memory token must still be set
|
||||
expect(getAuthToken()).toBe('fallback-token');
|
||||
});
|
||||
|
||||
it('setAuthToken(null) removes from sessionStorage even when localStorage.removeItem throws', () => {
|
||||
setAuthToken('some-token', 'persistent');
|
||||
vi.mocked(localStorage.removeItem).mockImplementationOnce(() => {
|
||||
throw new DOMException('SecurityError');
|
||||
});
|
||||
// Must not throw — localStorage failure must not abort the sessionStorage removal
|
||||
expect(() => setAuthToken(null)).not.toThrow();
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
|
||||
expect(getAuthToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('setAuthToken(null) removes from localStorage even when sessionStorage.removeItem throws', () => {
|
||||
setAuthToken('some-token', 'persistent');
|
||||
sessionStorageMock.removeItem.mockImplementationOnce(() => {
|
||||
throw new DOMException('SecurityError');
|
||||
});
|
||||
// Must not throw — sessionStorage failure must not abort the localStorage removal
|
||||
expect(() => setAuthToken(null)).not.toThrow();
|
||||
expect(vi.mocked(localStorage.removeItem)).toHaveBeenCalledWith('auth_token');
|
||||
expect(getAuthToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Client Auth Header', () => {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Tests for OIDCProviderSettings — focused on the auto_link / require_email_verified
|
||||
* toggle interaction (SEC-1/SEC-6 UI enforcement).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { OIDCProviderSettings } from '../../components/OIDCProviderSettings';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestIdP',
|
||||
issuer_url: 'https://idp.example.com',
|
||||
client_id: 'test-client',
|
||||
scopes: 'openid email profile',
|
||||
is_enabled: true,
|
||||
auto_create_users: false,
|
||||
auto_link_existing_accounts: false,
|
||||
email_claim: 'email',
|
||||
require_email_verified: true,
|
||||
icon_url: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json(mockProviders))
|
||||
);
|
||||
});
|
||||
|
||||
describe('OIDCProviderSettings', () => {
|
||||
describe('ProviderForm — require_email_verified description logic', () => {
|
||||
it('shows standard description when require_email_verified is on and auto_link is off', async () => {
|
||||
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
|
||||
render(<OIDCProviderSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
// Default state: require_email_verified=true, auto_link=false → standard description
|
||||
expect(
|
||||
screen.getByText(/only.*accept.*email.*verified/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Disable auto-link first" description when auto_link is enabled', async () => {
|
||||
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
|
||||
const user = userEvent.setup();
|
||||
render(<OIDCProviderSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Auto.*Link/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the Auto Link switch by aria-label or by position
|
||||
const switches = screen.getAllByRole('switch');
|
||||
// Switches order in form: Enabled, AutoCreate, AutoLink, RequireEmailVerified
|
||||
// AutoLink is the 3rd switch (index 2)
|
||||
const autoLinkSwitch = switches[2];
|
||||
await user.click(autoLinkSwitch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/disable auto.?link first/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows warning text when require_email_verified is toggled off', async () => {
|
||||
server.use(http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([])));
|
||||
const user = userEvent.setup();
|
||||
render(<OIDCProviderSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('button', { name: /Add Provider/i })[0]).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getAllByRole('button', { name: /Add Provider/i })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// RequireEmailVerified is the 4th switch (index 3)
|
||||
const switches = screen.getAllByRole('switch');
|
||||
const reqEvSwitch = switches[3];
|
||||
await user.click(reqEvSwitch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/warning.*accept.*without.*verif/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider info view', () => {
|
||||
it('renders email_claim and require_email_verified fields in provider details', async () => {
|
||||
render(<OIDCProviderSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestIdP')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The provider card shows field labels in the details section
|
||||
expect(screen.getByText(/Email Claim/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Require Email Verified/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tests for the LoginPage component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
@@ -300,4 +300,331 @@ describe('LoginPage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remember Me', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
role: 'admin' as const,
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(localStorage.setItem).mockClear();
|
||||
sessionStorage.clear();
|
||||
server.use(
|
||||
http.post('/api/v1/auth/login', () =>
|
||||
HttpResponse.json({
|
||||
access_token: 'test-token',
|
||||
token_type: 'bearer',
|
||||
user: mockUser,
|
||||
})
|
||||
),
|
||||
// Prevent checkAuthStatus from clearing the token when getCurrentUser is called
|
||||
http.get('/api/v1/auth/me', () => HttpResponse.json(mockUser))
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Remember Me checkbox on credentials step', async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Remember Me/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /Remember Me/i })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('does not persist token to localStorage when unchecked (default)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/Username/i), 'testuser');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
// Token must be in sessionStorage (tab-only) but not in localStorage
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
|
||||
expect(sessionStorage.getItem('auth_token')).toBe('test-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('persists token to localStorage when Remember Me is checked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
|
||||
await user.type(screen.getByLabelText(/Username/i), 'testuser');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'test-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('carries Remember Me through 2FA verification', async () => {
|
||||
server.use(
|
||||
http.post('/api/v1/auth/login', () =>
|
||||
HttpResponse.json({
|
||||
requires_2fa: true,
|
||||
pre_auth_token: 'pre-token',
|
||||
two_fa_methods: ['totp'],
|
||||
})
|
||||
),
|
||||
http.post('/api/v1/auth/2fa/verify', () =>
|
||||
HttpResponse.json({
|
||||
access_token: 'final-token',
|
||||
token_type: 'bearer',
|
||||
user: mockUser,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check Remember Me before submitting credentials
|
||||
await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
|
||||
await user.type(screen.getByLabelText(/Username/i), 'testuser');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
// Now on 2FA step — enter code and verify
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
|
||||
await user.click(screen.getByRole('button', { name: /Verify/i }));
|
||||
|
||||
// Token must be persisted to localStorage because Remember Me was checked
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'final-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('checkbox is not shown on 2FA step', async () => {
|
||||
server.use(
|
||||
http.post('/api/v1/auth/login', () =>
|
||||
HttpResponse.json({
|
||||
requires_2fa: true,
|
||||
pre_auth_token: 'pre-token',
|
||||
two_fa_methods: ['totp'],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/Username/i), 'testuser');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText(/Remember Me/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC with Remember Me', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'oidcuser',
|
||||
role: 'admin' as const,
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(localStorage.setItem).mockClear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.location.hash = '';
|
||||
window.history.pushState({}, '', '/login');
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('persists token to localStorage after OIDC redirect when Remember Me was set', async () => {
|
||||
sessionStorage.setItem('auth_remember_me', '1');
|
||||
server.use(
|
||||
http.post('/api/v1/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({
|
||||
access_token: 'oidc-token',
|
||||
token_type: 'bearer',
|
||||
user: mockUser,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
window.location.hash = '#oidc_token=test-exchange-token';
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-token');
|
||||
});
|
||||
expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
|
||||
});
|
||||
|
||||
it('carries Remember Me through OIDC + 2FA flow', async () => {
|
||||
sessionStorage.setItem('auth_remember_me', '1');
|
||||
server.use(
|
||||
http.post('/api/v1/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({
|
||||
requires_2fa: true,
|
||||
pre_auth_token: 'oidc-pre-token',
|
||||
two_fa_methods: ['totp'],
|
||||
})
|
||||
),
|
||||
http.post('/api/v1/auth/2fa/verify', () =>
|
||||
HttpResponse.json({
|
||||
access_token: 'oidc-2fa-token',
|
||||
token_type: 'bearer',
|
||||
user: mockUser,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
window.location.hash = '#oidc_token=test-exchange-token';
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Two-Factor Authentication/i })).toBeInTheDocument();
|
||||
});
|
||||
// Flag consumed on mount — no stale value for future flows
|
||||
expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
|
||||
|
||||
await user.type(screen.getByRole('textbox', { name: /Verification Code/i }), '123456');
|
||||
await user.click(screen.getByRole('button', { name: /Verify/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(localStorage.setItem)).toHaveBeenCalledWith('auth_token', 'oidc-2fa-token');
|
||||
});
|
||||
});
|
||||
|
||||
it('cleans up auth_remember_me flag when OIDC returns an error', async () => {
|
||||
sessionStorage.setItem('auth_remember_me', '1');
|
||||
window.history.pushState({}, '', '/login?oidc_error=invalid_state');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not persist token to localStorage after OIDC redirect when Remember Me was not set', async () => {
|
||||
// No auth_remember_me flag set — token must stay session-only
|
||||
server.use(
|
||||
http.post('/api/v1/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({
|
||||
access_token: 'oidc-session-token',
|
||||
token_type: 'bearer',
|
||||
user: mockUser,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
window.location.hash = '#oidc_token=test-exchange-token';
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('auth_token')).toBe('oidc-session-token');
|
||||
});
|
||||
expect(vi.mocked(localStorage.setItem)).not.toHaveBeenCalledWith('auth_token', expect.any(String));
|
||||
});
|
||||
|
||||
it('shows error toast when OIDC exchange returns unexpected response shape', async () => {
|
||||
sessionStorage.setItem('auth_remember_me', '1');
|
||||
server.use(
|
||||
// Response is missing both access_token and requires_2fa — hits the else branch
|
||||
http.post('/api/v1/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({ token_type: 'bearer' })
|
||||
)
|
||||
);
|
||||
|
||||
window.location.hash = '#oidc_token=test-exchange-token';
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Login.*failed|failed.*login/i)).toBeInTheDocument();
|
||||
});
|
||||
// Flag must still be cleaned up even on malformed response
|
||||
expect(sessionStorage.getItem('auth_remember_me')).toBeNull();
|
||||
});
|
||||
|
||||
it('writes auth_remember_me flag to sessionStorage before OIDC provider redirect', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/auth/oidc/providers', () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
id: 42,
|
||||
name: 'FlagIdP',
|
||||
issuer_url: 'https://flag.test',
|
||||
client_id: 'c',
|
||||
is_enabled: true,
|
||||
icon_url: null,
|
||||
email_claim: 'email',
|
||||
require_email_verified: true,
|
||||
auto_create_users: false,
|
||||
auto_link_existing_accounts: false,
|
||||
},
|
||||
])
|
||||
),
|
||||
http.get('/api/v1/auth/oidc/authorize/42', () =>
|
||||
HttpResponse.json({ auth_url: 'https://flag.test/authorize?state=abc' })
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
// Tick "Remember Me"
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('checkbox', { name: /Remember Me/i })).toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('checkbox', { name: /Remember Me/i }));
|
||||
|
||||
// Wait for OIDC provider button to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /FlagIdP/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Stub window.location so the OIDC redirect doesn't actually navigate.
|
||||
// Keep href valid so relative fetch URLs resolve correctly.
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, href: 'http://localhost:3000/' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /FlagIdP/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('auth_remember_me')).toBe('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
-11
@@ -2,23 +2,33 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// Auth token storage
|
||||
// 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.
|
||||
// 'persistent' also writes to localStorage so the token survives tab close
|
||||
// (used by Remember Me and the ?token= kiosk bootstrap).
|
||||
let authToken: string | null =
|
||||
sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
|
||||
|
||||
export function setAuthToken(token: string | null, persist = false) {
|
||||
export type TokenPersistence = 'session' | 'persistent';
|
||||
|
||||
export function setAuthToken(token: string | null, persistence: TokenPersistence = 'session') {
|
||||
authToken = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem('auth_token', token);
|
||||
if (persist) {
|
||||
try {
|
||||
if (token) {
|
||||
sessionStorage.setItem('auth_token', token);
|
||||
} else {
|
||||
sessionStorage.removeItem('auth_token');
|
||||
}
|
||||
} catch (err) {
|
||||
// Storage unavailable (quota exceeded, private mode): in-memory token still works for this tab.
|
||||
console.warn('setAuthToken: sessionStorage unavailable, token kept in-memory only', err);
|
||||
}
|
||||
try {
|
||||
if (!token) {
|
||||
localStorage.removeItem('auth_token');
|
||||
} else if (persistence === 'persistent') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
} else {
|
||||
sessionStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_token');
|
||||
} catch (err) {
|
||||
console.warn('setAuthToken: localStorage operation failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2521,6 +2531,8 @@ export interface OIDCProvider {
|
||||
is_enabled: boolean;
|
||||
auto_create_users: boolean;
|
||||
auto_link_existing_accounts: boolean;
|
||||
email_claim: string;
|
||||
require_email_verified: boolean;
|
||||
icon_url?: string | null;
|
||||
}
|
||||
|
||||
@@ -2533,6 +2545,8 @@ export interface OIDCProviderCreate {
|
||||
is_enabled?: boolean;
|
||||
auto_create_users?: boolean;
|
||||
auto_link_existing_accounts?: boolean;
|
||||
email_claim?: string;
|
||||
require_email_verified?: boolean;
|
||||
icon_url?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type ReactNode } 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';
|
||||
@@ -19,6 +19,8 @@ const EMPTY_FORM: OIDCProviderCreate = {
|
||||
is_enabled: true,
|
||||
auto_create_users: false,
|
||||
auto_link_existing_accounts: false,
|
||||
email_claim: 'email',
|
||||
require_email_verified: true,
|
||||
icon_url: undefined,
|
||||
};
|
||||
|
||||
@@ -54,6 +56,19 @@ function ProviderForm({
|
||||
onSave(payload);
|
||||
};
|
||||
|
||||
const autoLinkOn = form.auto_link_existing_accounts === true;
|
||||
const emailVerifiedOn = form.require_email_verified ?? true;
|
||||
let requireEmailVerifiedDesc: ReactNode;
|
||||
if (autoLinkOn) {
|
||||
requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedAutoLink');
|
||||
} else if (emailVerifiedOn) {
|
||||
requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedDesc');
|
||||
} else {
|
||||
requireEmailVerifiedDesc = (
|
||||
<span className="text-red-400">{t('settings.oidc.form.requireEmailVerifiedWarning')}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -115,6 +130,28 @@ function ProviderForm({
|
||||
<p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<Toggle
|
||||
checked={emailVerifiedOn}
|
||||
onChange={(v) => set('require_email_verified', v)}
|
||||
disabled={autoLinkOn}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white text-sm">{t('settings.oidc.form.requireEmailVerified')}</p>
|
||||
<p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.oidc.form.emailClaim')}</label>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={form.email_claim}
|
||||
onChange={(e) => set('email_claim', e.target.value || 'email')}
|
||||
placeholder={t('settings.oidc.form.emailClaimPlaceholder')}
|
||||
/>
|
||||
<p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.emailClaimDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
@@ -304,6 +341,8 @@ export function OIDCProviderSettings() {
|
||||
is_enabled: provider.is_enabled,
|
||||
auto_create_users: provider.auto_create_users,
|
||||
auto_link_existing_accounts: provider.auto_link_existing_accounts,
|
||||
email_claim: provider.email_claim,
|
||||
require_email_verified: provider.require_email_verified,
|
||||
icon_url: provider.icon_url ?? undefined,
|
||||
}}
|
||||
onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
|
||||
@@ -337,6 +376,16 @@ export function OIDCProviderSettings() {
|
||||
{provider.auto_link_existing_accounts ? t('common.yes') : t('common.no')}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-bambu-gray">{t('settings.oidc.form.emailClaim')}</dt>
|
||||
<dd className="text-white font-mono">{provider.email_claim}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-bambu-gray">{t('settings.oidc.form.requireEmailVerified')}</dt>
|
||||
<dd className={provider.require_email_verified ? 'text-green-400' : 'text-red-400'}>
|
||||
{provider.require_email_verified ? t('common.yes') : t('common.no')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, getAuthToken, setAuthToken } from '../api/client';
|
||||
import type { LoginResponse, Permission, UserResponse } from '../api/client';
|
||||
import type { LoginResponse, Permission, TokenPersistence, UserResponse } from '../api/client';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserResponse | null;
|
||||
@@ -9,9 +9,9 @@ interface AuthContextType {
|
||||
loading: boolean;
|
||||
isAdmin: boolean;
|
||||
/** Login with username/password. Returns LoginResponse (may include requires_2fa). */
|
||||
login: (username: string, password: string) => Promise<LoginResponse>;
|
||||
login: (username: string, password: string, persistence?: TokenPersistence) => Promise<LoginResponse>;
|
||||
/** Finalise login after 2FA or OIDC — store token and set user directly. */
|
||||
loginWithToken: (token: string, user: UserResponse) => void;
|
||||
loginWithToken: (token: string, user: UserResponse, persistence?: TokenPersistence) => void;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
refreshAuth: () => Promise<void>;
|
||||
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlToken = urlParams.get('token');
|
||||
if (urlToken) {
|
||||
setAuthToken(urlToken, false); // session-only until server confirms it's valid
|
||||
setAuthToken(urlToken, 'session'); // session-only until server confirms it's valid
|
||||
urlParams.delete('token');
|
||||
const cleanSearch = urlParams.toString();
|
||||
const cleanUrl = window.location.pathname
|
||||
@@ -64,7 +64,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser(currentUser);
|
||||
// Persist kiosk token only after the server confirms it is valid.
|
||||
if (urlToken && token === urlToken) {
|
||||
setAuthToken(urlToken, true);
|
||||
setAuthToken(urlToken, 'persistent');
|
||||
}
|
||||
} catch {
|
||||
// Token invalid, clear it (removes from both sessionStorage and localStorage)
|
||||
@@ -116,17 +116,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [loading, requiresSetup, authEnabled]);
|
||||
|
||||
const login = async (username: string, password: string): Promise<LoginResponse> => {
|
||||
const login = async (username: string, password: string, persistence: TokenPersistence = 'session'): Promise<LoginResponse> => {
|
||||
const response = await api.login({ username, password });
|
||||
if (!response.requires_2fa && response.access_token) {
|
||||
setAuthToken(response.access_token);
|
||||
setAuthToken(response.access_token, persistence);
|
||||
await checkAuthStatus();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const loginWithToken = (token: string, userObj: UserResponse) => {
|
||||
setAuthToken(token);
|
||||
const loginWithToken = (token: string, userObj: UserResponse, persistence: TokenPersistence = 'session') => {
|
||||
setAuthToken(token, persistence);
|
||||
setUser(userObj);
|
||||
setAuthEnabled(true);
|
||||
};
|
||||
|
||||
@@ -2202,6 +2202,13 @@ export default {
|
||||
autoLinkDesc: 'Verknüpft beim ersten Login vorhandene lokale Konten anhand der E-Mail-Adresse.',
|
||||
secretHint: 'leer lassen zum Beibehalten',
|
||||
secretPlaceholder: 'neues Secret',
|
||||
emailClaim: 'E-Mail-Claim',
|
||||
emailClaimDesc: "JWT-Claim für die E-Mail-Identität. Für Azure Entra ID 'preferred_username' oder 'upn' verwenden (sendet kein email_verified). Nur vertrauenswürdige Claim-Namen verwenden.",
|
||||
emailClaimPlaceholder: 'email',
|
||||
requireEmailVerified: 'E-Mail-Verifizierung erforderlich',
|
||||
requireEmailVerifiedDesc: 'E-Mail-Claim nur akzeptieren, wenn der Provider ihn als verifiziert markiert.',
|
||||
requireEmailVerifiedWarning: 'Warnung: E-Mail wird auch ohne Verifizierung akzeptiert. Nur bei vertrauenswürdigen Providern verwenden.',
|
||||
requireEmailVerifiedAutoLink: 'Auto-Verknüpfung zuerst deaktivieren, um diese Einstellung zu ändern.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2331,6 +2338,7 @@ export default {
|
||||
passwordPlaceholder: 'Passwort eingeben',
|
||||
signIn: 'Anmelden',
|
||||
signingIn: 'Anmeldung läuft...',
|
||||
rememberMe: 'Angemeldet bleiben',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
loginSuccess: 'Erfolgreich angemeldet',
|
||||
loginFailed: 'Anmeldung fehlgeschlagen',
|
||||
|
||||
@@ -2205,6 +2205,13 @@ export default {
|
||||
autoLinkDesc: 'Link existing local accounts by matching email on first login.',
|
||||
secretHint: 'leave blank to keep current',
|
||||
secretPlaceholder: 'new secret',
|
||||
emailClaim: 'Email Claim',
|
||||
emailClaimDesc: "JWT claim used as email identity. Use 'preferred_username' or 'upn' for Azure Entra ID (which does not send email_verified). Only use trusted claim names.",
|
||||
emailClaimPlaceholder: 'email',
|
||||
requireEmailVerified: 'Require email verified',
|
||||
requireEmailVerifiedDesc: 'Only accept the email claim when the provider marks it as verified.',
|
||||
requireEmailVerifiedWarning: 'Warning: email will be accepted even without verification. Use only with trusted providers.',
|
||||
requireEmailVerifiedAutoLink: 'Disable auto-link first to change this setting.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2334,6 +2341,7 @@ export default {
|
||||
passwordPlaceholder: 'Enter your password',
|
||||
signIn: 'Sign in',
|
||||
signingIn: 'Logging in...',
|
||||
rememberMe: 'Remember Me',
|
||||
forgotPassword: 'Forgot your password?',
|
||||
loginSuccess: 'Logged in successfully',
|
||||
loginFailed: 'Login failed',
|
||||
|
||||
@@ -2138,6 +2138,13 @@ export default {
|
||||
autoLinkDesc: 'Lie les comptes locaux existants par e-mail lors de la première connexion.',
|
||||
secretHint: 'laisser vide pour conserver',
|
||||
secretPlaceholder: 'nouveau secret',
|
||||
emailClaim: 'Claim e-mail',
|
||||
emailClaimDesc: "Claim JWT utilisé comme identité e-mail. Utiliser 'preferred_username' ou 'upn' pour Azure Entra ID (qui n'envoie pas email_verified). Utiliser uniquement des noms de claims de confiance.",
|
||||
emailClaimPlaceholder: 'email',
|
||||
requireEmailVerified: 'Exiger la vérification e-mail',
|
||||
requireEmailVerifiedDesc: "N'accepter le claim e-mail que si le fournisseur le marque comme vérifié.",
|
||||
requireEmailVerifiedWarning: "Avertissement : l'e-mail sera accepté sans vérification. À utiliser uniquement avec des fournisseurs de confiance.",
|
||||
requireEmailVerifiedAutoLink: 'Désactiver le lien automatique d\'abord pour modifier ce paramètre.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2267,6 +2274,7 @@ export default {
|
||||
passwordPlaceholder: 'Entrez votre mot de passe',
|
||||
signIn: 'Se connecter',
|
||||
signingIn: 'Connexion...',
|
||||
rememberMe: 'Se souvenir de moi',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
loginSuccess: 'Connecté avec succès',
|
||||
loginFailed: 'Échec de connexion',
|
||||
|
||||
@@ -2137,6 +2137,13 @@ export default {
|
||||
autoLinkDesc: 'Collega gli account locali esistenti tramite email al primo accesso.',
|
||||
secretHint: 'lascia vuoto per mantenere',
|
||||
secretPlaceholder: 'nuovo segreto',
|
||||
emailClaim: 'Claim email',
|
||||
emailClaimDesc: "Claim JWT usato come identità email. Usare 'preferred_username' o 'upn' per Azure Entra ID (che non invia email_verified). Usare solo nomi di claim affidabili.",
|
||||
emailClaimPlaceholder: 'email',
|
||||
requireEmailVerified: 'Richiedi verifica email',
|
||||
requireEmailVerifiedDesc: "Accetta il claim email solo se il provider lo contrassegna come verificato.",
|
||||
requireEmailVerifiedWarning: 'Attenzione: l\'email sarà accettata senza verifica. Usare solo con provider affidabili.',
|
||||
requireEmailVerifiedAutoLink: 'Disabilitare prima il collegamento automatico per modificare questa impostazione.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2266,6 +2273,7 @@ export default {
|
||||
passwordPlaceholder: 'Inserisci la password',
|
||||
signIn: 'Accedi',
|
||||
signingIn: 'Accesso in corso...',
|
||||
rememberMe: 'Ricordami',
|
||||
forgotPassword: 'Hai dimenticato la password?',
|
||||
loginSuccess: 'Accesso riuscito',
|
||||
loginFailed: 'Accesso fallito',
|
||||
|
||||
@@ -2176,6 +2176,13 @@ export default {
|
||||
autoLinkDesc: '初回ログイン時にメールアドレスで既存のローカルアカウントにリンクします。',
|
||||
secretHint: '空白のままで現在のものを維持',
|
||||
secretPlaceholder: '新しいシークレット',
|
||||
emailClaim: 'メールクレーム',
|
||||
emailClaimDesc: "メールIDとして使用するJWTクレーム。Azure Entra IDには'preferred_username'または'upn'を使用(email_verifiedを送信しない)。信頼できるクレーム名のみ使用してください。",
|
||||
emailClaimPlaceholder: 'email',
|
||||
requireEmailVerified: 'メール確認を要求',
|
||||
requireEmailVerifiedDesc: 'プロバイダーが確認済みとしてマークした場合にのみメールクレームを受け入れます。',
|
||||
requireEmailVerifiedWarning: '警告:確認なしでメールが受け入れられます。信頼できるプロバイダーのみで使用してください。',
|
||||
requireEmailVerifiedAutoLink: 'この設定を変更するには、まず自動リンクを無効にしてください。',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2305,6 +2312,7 @@ export default {
|
||||
passwordPlaceholder: 'パスワードを入力',
|
||||
signIn: 'サインイン',
|
||||
signingIn: 'ログイン中...',
|
||||
rememberMe: 'ログイン状態を保持する',
|
||||
forgotPassword: 'パスワードをお忘れですか?',
|
||||
loginSuccess: 'ログインしました',
|
||||
loginFailed: 'ログインに失敗しました',
|
||||
|
||||
@@ -2137,6 +2137,13 @@ export default {
|
||||
autoLinkDesc: 'Vincula contas locais existentes por e-mail no primeiro login.',
|
||||
secretHint: 'deixe em branco para manter',
|
||||
secretPlaceholder: 'novo segredo',
|
||||
emailClaim: 'Claim de e-mail',
|
||||
emailClaimDesc: "Claim JWT usado como identidade de e-mail. Use 'preferred_username' ou 'upn' para Azure Entra ID (que não envia email_verified). Use apenas nomes de claim confiáveis.",
|
||||
emailClaimPlaceholder: 'email',
|
||||
requireEmailVerified: 'Exigir e-mail verificado',
|
||||
requireEmailVerifiedDesc: 'Aceitar o claim de e-mail apenas quando o provedor o marcar como verificado.',
|
||||
requireEmailVerifiedWarning: 'Aviso: o e-mail será aceito sem verificação. Use apenas com provedores confiáveis.',
|
||||
requireEmailVerifiedAutoLink: 'Desabilite o vínculo automático primeiro para alterar esta configuração.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2266,6 +2273,7 @@ export default {
|
||||
passwordPlaceholder: 'Digite sua senha',
|
||||
signIn: 'Entrar',
|
||||
signingIn: 'Entrando...',
|
||||
rememberMe: 'Lembrar de mim',
|
||||
forgotPassword: 'Esqueceu sua senha?',
|
||||
loginSuccess: 'Login realizado com sucesso',
|
||||
loginFailed: 'Falha no login',
|
||||
|
||||
@@ -2189,6 +2189,13 @@ export default {
|
||||
autoLinkDesc: '首次登录时通过邮箱匹配现有本地账户并自动关联。',
|
||||
secretHint: '留空以保留当前',
|
||||
secretPlaceholder: '新密钥',
|
||||
emailClaim: '邮箱声明',
|
||||
emailClaimDesc: "用作邮箱身份的 JWT 声明。Azure Entra ID 请使用 'preferred_username' 或 'upn'(不发送 email_verified)。仅使用可信的声明名称。",
|
||||
emailClaimPlaceholder: 'email',
|
||||
requireEmailVerified: '要求邮箱已验证',
|
||||
requireEmailVerifiedDesc: '仅在提供商将邮箱声明标记为已验证时才接受。',
|
||||
requireEmailVerifiedWarning: '警告:将在未经验证的情况下接受邮箱。仅对受信任的提供商使用。',
|
||||
requireEmailVerifiedAutoLink: '请先禁用自动关联以更改此设置。',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2318,6 +2325,7 @@ export default {
|
||||
passwordPlaceholder: '输入您的密码',
|
||||
signIn: '登录',
|
||||
signingIn: '登录中...',
|
||||
rememberMe: '记住我',
|
||||
forgotPassword: '忘记密码?',
|
||||
loginSuccess: '登录成功',
|
||||
loginFailed: '登录失败',
|
||||
|
||||
@@ -2187,6 +2187,13 @@ export default {
|
||||
autoCreateDesc: '首次登入時自動建立本機帳戶。',
|
||||
autoLink: '自動連結已有帳戶',
|
||||
autoLinkDesc: '首次登入時透過信箱匹配現有本機帳戶並自動連結。',
|
||||
emailClaim: '電子郵件聲明',
|
||||
emailClaimDesc: "用作電子郵件身份的 JWT 聲明。Azure Entra ID 請使用 'preferred_username' 或 'upn'(不發送 email_verified)。僅使用可信的聲明名稱。",
|
||||
emailClaimPlaceholder: 'email',
|
||||
requireEmailVerified: '要求電子郵件已驗證',
|
||||
requireEmailVerifiedDesc: '僅在提供商將電子郵件聲明標記為已驗證時才接受。',
|
||||
requireEmailVerifiedWarning: '警告:將在未經驗證的情況下接受電子郵件。僅對受信任的提供商使用。',
|
||||
requireEmailVerifiedAutoLink: '請先停用自動連結以變更此設定。',
|
||||
secretHint: '留空以保留目前',
|
||||
secretPlaceholder: '新金鑰',
|
||||
},
|
||||
@@ -2318,6 +2325,7 @@ export default {
|
||||
passwordPlaceholder: '輸入您的密碼',
|
||||
signIn: '登入',
|
||||
signingIn: '登入中...',
|
||||
rememberMe: '記住我',
|
||||
forgotPassword: '忘記密碼?',
|
||||
loginSuccess: '登入成功',
|
||||
loginFailed: '登入失敗',
|
||||
|
||||
@@ -6,12 +6,32 @@ import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { X, Mail, Shield, Smartphone, Key } from 'lucide-react';
|
||||
import { api, type LoginResponse } from '../api/client';
|
||||
import { api, type LoginResponse, type TokenPersistence } from '../api/client';
|
||||
import { Card, CardHeader, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
type LoginStep = 'credentials' | '2fa' | 'reset-password';
|
||||
|
||||
// sessionStorage survives the OIDC provider round-trip; React state does not.
|
||||
// Read + remove in one try so all branches in the OIDC useEffect see the same
|
||||
// value and a subsequent page load does not replay the flag.
|
||||
const REMEMBER_ME_KEY = 'auth_remember_me';
|
||||
|
||||
function toPersistence(remember: boolean): TokenPersistence {
|
||||
return remember ? 'persistent' : 'session';
|
||||
}
|
||||
|
||||
function consumeSavedRememberMe(): boolean {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(REMEMBER_ME_KEY) === '1';
|
||||
sessionStorage.removeItem(REMEMBER_ME_KEY);
|
||||
return saved;
|
||||
} catch (err) {
|
||||
console.warn('consumeSavedRememberMe: sessionStorage unavailable, Remember Me preference lost across OIDC redirect', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -35,6 +55,8 @@ export function LoginPage() {
|
||||
const [emailOTPSent, setEmailOTPSent] = useState(false);
|
||||
const twoFAInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
|
||||
// H-6: Password reset step state
|
||||
const [resetToken, setResetToken] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
@@ -74,6 +96,10 @@ export function LoginPage() {
|
||||
const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null;
|
||||
const oidcError = searchParams.get('oidc_error');
|
||||
|
||||
if (!oidcToken && !oidcError) return;
|
||||
|
||||
const savedRememberMe = consumeSavedRememberMe();
|
||||
|
||||
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.
|
||||
@@ -100,7 +126,6 @@ export function LoginPage() {
|
||||
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;
|
||||
}
|
||||
@@ -109,6 +134,7 @@ export function LoginPage() {
|
||||
api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => {
|
||||
if (resp.requires_2fa && resp.pre_auth_token) {
|
||||
// OIDC user has 2FA enabled — redirect to 2FA step
|
||||
setRememberMe(savedRememberMe);
|
||||
setPreAuthToken(resp.pre_auth_token);
|
||||
const methods = resp.two_fa_methods ?? [];
|
||||
setTwoFAMethods(methods);
|
||||
@@ -119,12 +145,16 @@ export function LoginPage() {
|
||||
// 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);
|
||||
loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe));
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
showToast(t('login.oidcLoginFailed'), 'error');
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}).catch((err: Error) => {
|
||||
showToast(err.message || t('login.oidcLoginFailed'), 'error');
|
||||
}).catch((err: unknown) => {
|
||||
console.error('OIDC token exchange failed', err);
|
||||
showToast(t('login.oidcLoginFailed'), 'error');
|
||||
navigate('/login', { replace: true });
|
||||
});
|
||||
}
|
||||
@@ -132,7 +162,7 @@ export function LoginPage() {
|
||||
|
||||
// --- Step 1: Credentials login ---
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: () => login(username, password),
|
||||
mutationFn: () => login(username, password, toPersistence(rememberMe)),
|
||||
onSuccess: (resp: LoginResponse) => {
|
||||
if (resp.requires_2fa && resp.pre_auth_token) {
|
||||
// 2FA required — switch to verification step
|
||||
@@ -200,9 +230,12 @@ export function LoginPage() {
|
||||
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);
|
||||
loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe));
|
||||
showToast(t('login.loginSuccess'));
|
||||
navigate('/');
|
||||
} else {
|
||||
console.error('2FA verify: unexpected response shape', resp);
|
||||
showToast(t('login.loginFailed'), 'error');
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -215,6 +248,13 @@ export function LoginPage() {
|
||||
const oidcLoginMutation = useMutation({
|
||||
mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId),
|
||||
onSuccess: (data) => {
|
||||
if (rememberMe) {
|
||||
try {
|
||||
sessionStorage.setItem(REMEMBER_ME_KEY, '1');
|
||||
} catch (err) {
|
||||
console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err);
|
||||
}
|
||||
}
|
||||
window.location.href = data.auth_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
@@ -568,6 +608,19 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="remember-me"
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="text-sm text-bambu-gray cursor-pointer">
|
||||
{t('login.rememberMe')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
Reference in New Issue
Block a user