Stage 6 Phase 0-1: Foundation — Switch component, new types, useAuth rewrite, useSettings cleanup

- Create switch.tsx: pure Tailwind Switch toggle, shadcn/ui pattern (forwardRef, cn(), role=switch, aria-checked)
- types/index.ts: extend Settings with all ntfy fields; add LoginSuccessResponse, LoginMfaRequiredResponse, LoginResponse discriminated union, TotpSetupResponse
- useAuth.ts: rewrite with username/password login, ephemeral mfaToken state, totpVerifyMutation, mfaRequired boolean, full mutation exports
- useSettings.ts: remove changePinMutation logic; keep deprecated changePin/isChangingPin stubs for compile compatibility until SettingsPage is rewritten in Phase 3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-25 04:02:55 +08:00
parent 5feb67bf13
commit 7f0ae0b6ef
4 changed files with 112 additions and 6 deletions

View File

@ -0,0 +1,43 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
disabled?: boolean;
}
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent',
'transition-colors duration-200 ease-in-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-accent' : 'bg-input',
className
)}
ref={ref}
{...props}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-lg',
'transform transition-transform duration-200 ease-in-out',
checked ? 'translate-x-4' : 'translate-x-0'
)}
/>
</button>
);
}
);
Switch.displayName = 'Switch';
export { Switch };

View File

@ -1,9 +1,12 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { AuthStatus } from '@/types'; import type { AuthStatus, LoginResponse } from '@/types';
export function useAuth() { export function useAuth() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Ephemeral MFA token — not in TanStack cache, lives only during the TOTP challenge step
const [mfaToken, setMfaToken] = useState<string | null>(null);
const authQuery = useQuery({ const authQuery = useQuery({
queryKey: ['auth'], queryKey: ['auth'],
@ -15,18 +18,38 @@ export function useAuth() {
}); });
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationFn: async (pin: string) => { mutationFn: async ({ username, password }: { username: string; password: string }) => {
const { data } = await api.post('/auth/login', { pin }); const { data } = await api.post<LoginResponse>('/auth/login', { username, password });
return data;
},
onSuccess: (data) => {
if ('mfa_token' in data && data.totp_required) {
// MFA required — store token locally, do NOT mark as authenticated yet
setMfaToken(data.mfa_token);
} else {
setMfaToken(null);
queryClient.invalidateQueries({ queryKey: ['auth'] });
}
},
});
const totpVerifyMutation = useMutation({
mutationFn: async (code: string) => {
const { data } = await api.post('/auth/totp-verify', {
mfa_token: mfaToken,
code,
});
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
setMfaToken(null);
queryClient.invalidateQueries({ queryKey: ['auth'] }); queryClient.invalidateQueries({ queryKey: ['auth'] });
}, },
}); });
const setupMutation = useMutation({ const setupMutation = useMutation({
mutationFn: async (pin: string) => { mutationFn: async ({ username, password }: { username: string; password: string }) => {
const { data } = await api.post('/auth/setup', { pin }); const { data } = await api.post('/auth/setup', { username, password });
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
@ -40,6 +63,7 @@ export function useAuth() {
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
setMfaToken(null);
queryClient.invalidateQueries({ queryKey: ['auth'] }); queryClient.invalidateQueries({ queryKey: ['auth'] });
}, },
}); });
@ -47,10 +71,13 @@ export function useAuth() {
return { return {
authStatus: authQuery.data, authStatus: authQuery.data,
isLoading: authQuery.isLoading, isLoading: authQuery.isLoading,
mfaRequired: mfaToken !== null,
login: loginMutation.mutateAsync, login: loginMutation.mutateAsync,
verifyTotp: totpVerifyMutation.mutateAsync,
setup: setupMutation.mutateAsync, setup: setupMutation.mutateAsync,
logout: logoutMutation.mutateAsync, logout: logoutMutation.mutateAsync,
isLoginPending: loginMutation.isPending, isLoginPending: loginMutation.isPending,
isTotpPending: totpVerifyMutation.isPending,
isSetupPending: setupMutation.isPending, isSetupPending: setupMutation.isPending,
}; };
} }

View File

@ -23,6 +23,9 @@ export function useSettings() {
}, },
}); });
// @deprecated — PIN auth is replaced by username/password in Stage 6.
// SettingsPage will be rewritten in Phase 3 to remove this. Kept here to
// preserve compilation until SettingsPage.tsx is updated.
const changePinMutation = useMutation({ const changePinMutation = useMutation({
mutationFn: async ({ oldPin, newPin }: { oldPin: string; newPin: string }) => { mutationFn: async ({ oldPin, newPin }: { oldPin: string; newPin: string }) => {
const { data } = await api.put('/settings/pin', { old_pin: oldPin, new_pin: newPin }); const { data } = await api.put('/settings/pin', { old_pin: oldPin, new_pin: newPin });
@ -34,8 +37,9 @@ export function useSettings() {
settings: settingsQuery.data, settings: settingsQuery.data,
isLoading: settingsQuery.isLoading, isLoading: settingsQuery.isLoading,
updateSettings: updateMutation.mutateAsync, updateSettings: updateMutation.mutateAsync,
changePin: changePinMutation.mutateAsync,
isUpdating: updateMutation.isPending, isUpdating: updateMutation.isPending,
// @deprecated — remove when SettingsPage is rewritten in Phase 3
changePin: changePinMutation.mutateAsync,
isChangingPin: changePinMutation.isPending, isChangingPin: changePinMutation.isPending,
}; };
} }

View File

@ -7,6 +7,18 @@ export interface Settings {
weather_lat?: number | null; weather_lat?: number | null;
weather_lon?: number | null; weather_lon?: number | null;
first_day_of_week: number; first_day_of_week: number;
// ntfy push notification fields
ntfy_server_url: string | null;
ntfy_topic: string | null;
ntfy_enabled: boolean;
ntfy_events_enabled: boolean;
ntfy_reminders_enabled: boolean;
ntfy_todos_enabled: boolean;
ntfy_projects_enabled: boolean;
ntfy_event_lead_minutes: number;
ntfy_todo_lead_days: number;
ntfy_project_lead_days: number;
ntfy_has_token: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -177,6 +189,26 @@ export interface AuthStatus {
setup_required: boolean; setup_required: boolean;
} }
// Login response discriminated union
export interface LoginSuccessResponse {
authenticated: true;
}
export interface LoginMfaRequiredResponse {
authenticated: false;
totp_required: true;
mfa_token: string;
}
export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse;
// TOTP setup response (from POST /api/auth/totp/setup)
export interface TotpSetupResponse {
secret: string;
qr_code_base64: string;
backup_codes: string[];
}
export interface DashboardData { export interface DashboardData {
todays_events: Array<{ todays_events: Array<{
id: number; id: number;