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:
@@ -53,6 +53,7 @@ function OAuthCallbackInner() {
|
||||
sessionStorage.removeItem("oauth_state");
|
||||
sessionStorage.removeItem("oauth_code_verifier");
|
||||
sessionStorage.removeItem("oauth_server_url");
|
||||
sessionStorage.removeItem("oauth_add_account_mode");
|
||||
let redirectTo = `/${params.locale}`;
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
|
||||
@@ -712,7 +712,7 @@ export default function CalendarPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
|
||||
@@ -552,7 +552,7 @@ export default function ContactsPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
|
||||
@@ -357,7 +357,7 @@ export default function FilesPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
|
||||
+24
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -28,6 +28,8 @@ export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
||||
@@ -102,7 +104,7 @@ export default function LoginPage() {
|
||||
}, [serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
if (isAuthenticated && !isAddAccountMode) {
|
||||
let redirectTo = '/';
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
@@ -113,7 +115,7 @@ export default function LoginPage() {
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
}, [isAuthenticated, router, isAddAccountMode]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
@@ -303,6 +305,9 @@ export default function LoginPage() {
|
||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||
sessionStorage.setItem("oauth_state", state);
|
||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
||||
if (isAddAccountMode) {
|
||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||
}
|
||||
|
||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
@@ -329,15 +334,7 @@ export default function LoginPage() {
|
||||
|
||||
if (success) {
|
||||
saveUsername(formData.username);
|
||||
let redirectTo = '/';
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
if (saved) {
|
||||
sessionStorage.removeItem('redirect_after_login');
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -426,10 +423,10 @@ export default function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
{appName}
|
||||
{isAddAccountMode ? t("add_account_title") : appName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1.5">
|
||||
{t("title") !== appName ? t("title") : "Sign in to your account"}
|
||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -737,6 +734,19 @@ export default function LoginPage() {
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isAddAccountMode && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full h-10 text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -769,7 +769,9 @@ export default function Home() {
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/login');
|
||||
if (!useAuthStore.getState().isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
|
||||
@@ -286,7 +286,7 @@ export default function SettingsPage() {
|
||||
{/* Logout */}
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={() => { logout(); router.push('/login'); }}
|
||||
onClick={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
@@ -317,7 +317,7 @@ export default function SettingsPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user