feat: implement account switcher component and state management

- Add AccountSwitcher component for managing user accounts with UI for switching, adding, and logging out.
- Create account state manager to handle snapshots of account-specific states for efficient switching.
- Introduce utility functions for account management, including ID generation and avatar color assignment.
- Implement Zustand store for account management, supporting addition, removal, and state retrieval of accounts.
This commit is contained in:
Linus Rath
2026-03-19 10:08:57 +01:00
parent 234129397d
commit d493bb17dc
26 changed files with 1398 additions and 119 deletions
+31 -8
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
const COOKIE_OPTIONS = {
httpOnly: true,
@@ -12,20 +12,30 @@ const COOKIE_OPTIONS = {
maxAge: SESSION_COOKIE_MAX_AGE,
};
function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
return slot;
}
export async function POST(request: NextRequest) {
try {
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
}
const { serverUrl, username, password } = await request.json();
const { serverUrl, username, password, slot: bodySlot } = await request.json();
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const cookieName = sessionCookieName(slot);
const token = encryptSession(serverUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
return NextResponse.json({ ok: true });
} catch (error) {
@@ -34,10 +44,12 @@ export async function POST(request: NextRequest) {
}
}
export async function GET() {
export async function GET(request: NextRequest) {
try {
const slot = getSlot(request);
const cookieName = sessionCookieName(slot);
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
const token = cookieStore.get(cookieName)?.value;
if (!token) {
return NextResponse.json({ error: 'No session' }, { status: 401 });
@@ -45,7 +57,7 @@ export async function GET() {
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(SESSION_COOKIE);
cookieStore.delete(cookieName);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
@@ -58,10 +70,21 @@ export async function GET() {
}
}
export async function DELETE() {
export async function DELETE(request: NextRequest) {
try {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) {
// Delete all session cookies (slots 0-4)
for (let i = 0; i <= 4; i++) {
cookieStore.delete(sessionCookieName(i));
}
} else {
const slot = getSlot(request);
cookieStore.delete(sessionCookieName(slot));
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
+52 -10
View File
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
@@ -14,6 +14,15 @@ const COOKIE_OPTIONS = {
maxAge: 30 * 24 * 60 * 60,
};
function getSlot(request: NextRequest): number {
const raw = request.nextUrl.searchParams.get('slot');
if (raw === null) return 0;
const slot = parseInt(raw, 10);
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
return slot;
}
function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID;
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
@@ -53,12 +62,13 @@ function buildOAuthParams(base: Record<string, string>): URLSearchParams {
export async function POST(request: NextRequest) {
try {
const { code, code_verifier, redirect_uri } = await request.json();
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
if (!code || !code_verifier || !redirect_uri) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
const tokenEndpoint = await getTokenEndpoint();
const params = buildOAuthParams({
@@ -93,8 +103,9 @@ export async function POST(request: NextRequest) {
});
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
}
return response;
@@ -104,10 +115,12 @@ export async function POST(request: NextRequest) {
}
}
export async function PUT() {
export async function PUT(request: NextRequest) {
try {
const slot = getSlot(request);
const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
const refreshToken = cookieStore.get(cookieName)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
@@ -129,7 +142,7 @@ export async function PUT() {
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
cookieStore.delete(REFRESH_TOKEN_COOKIE);
cookieStore.delete(cookieName);
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
@@ -141,7 +154,7 @@ export async function PUT() {
}
if (tokens.refresh_token) {
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
}
return NextResponse.json({
@@ -154,10 +167,39 @@ export async function PUT() {
}
}
export async function DELETE() {
export async function DELETE(request: NextRequest) {
try {
const all = request.nextUrl.searchParams.get('all') === 'true';
if (all) {
// Revoke and delete all refresh token cookies (slots 0-4)
const cookieStore = await cookies();
for (let i = 0; i <= 4; i++) {
const name = refreshTokenCookieName(i);
const token = cookieStore.get(name)?.value;
if (token) {
// Best-effort revocation
try {
const metadata = await getMetadata().catch(() => null);
if (metadata?.revocation_endpoint) {
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
await fetch(metadata.revocation_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
}).catch(() => {});
}
} catch { /* best effort */ }
cookieStore.delete(name);
}
}
return NextResponse.json({ ok: true });
}
const slot = getSlot(request);
const cookieName = refreshTokenCookieName(slot);
const cookieStore = await cookies();
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
const refreshToken = cookieStore.get(cookieName)?.value;
const metadata = await getMetadata().catch((err) => {
logger.warn('Failed to discover OAuth metadata during logout', {
error: err instanceof Error ? err.message : 'Unknown error',
@@ -186,7 +228,7 @@ export async function DELETE() {
}
}
cookieStore.delete(REFRESH_TOKEN_COOKIE);
cookieStore.delete(cookieName);
}
let end_session_url: string | undefined;