From 7f0ae0b6ef49382882de0a1c83f69589d7c3f467 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 25 Feb 2026 04:02:55 +0800 Subject: [PATCH] =?UTF-8?q?Stage=206=20Phase=200-1:=20Foundation=20?= =?UTF-8?q?=E2=80=94=20Switch=20component,=20new=20types,=20useAuth=20rewr?= =?UTF-8?q?ite,=20useSettings=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/ui/switch.tsx | 43 +++++++++++++++++++++++++++ frontend/src/hooks/useAuth.ts | 37 +++++++++++++++++++---- frontend/src/hooks/useSettings.ts | 6 +++- frontend/src/types/index.ts | 32 ++++++++++++++++++++ 4 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/ui/switch.tsx diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..0600c3c --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface SwitchProps extends Omit, 'onChange'> { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; + disabled?: boolean; +} + +const Switch = React.forwardRef( + ({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => { + return ( + + ); + } +); +Switch.displayName = 'Switch'; + +export { Switch }; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index b983116..e31ad48 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -1,9 +1,12 @@ +import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; -import type { AuthStatus } from '@/types'; +import type { AuthStatus, LoginResponse } from '@/types'; export function useAuth() { const queryClient = useQueryClient(); + // Ephemeral MFA token — not in TanStack cache, lives only during the TOTP challenge step + const [mfaToken, setMfaToken] = useState(null); const authQuery = useQuery({ queryKey: ['auth'], @@ -15,18 +18,38 @@ export function useAuth() { }); const loginMutation = useMutation({ - mutationFn: async (pin: string) => { - const { data } = await api.post('/auth/login', { pin }); + mutationFn: async ({ username, password }: { username: string; password: string }) => { + const { data } = await api.post('/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; }, onSuccess: () => { + setMfaToken(null); queryClient.invalidateQueries({ queryKey: ['auth'] }); }, }); const setupMutation = useMutation({ - mutationFn: async (pin: string) => { - const { data } = await api.post('/auth/setup', { pin }); + mutationFn: async ({ username, password }: { username: string; password: string }) => { + const { data } = await api.post('/auth/setup', { username, password }); return data; }, onSuccess: () => { @@ -40,6 +63,7 @@ export function useAuth() { return data; }, onSuccess: () => { + setMfaToken(null); queryClient.invalidateQueries({ queryKey: ['auth'] }); }, }); @@ -47,10 +71,13 @@ export function useAuth() { return { authStatus: authQuery.data, isLoading: authQuery.isLoading, + mfaRequired: mfaToken !== null, login: loginMutation.mutateAsync, + verifyTotp: totpVerifyMutation.mutateAsync, setup: setupMutation.mutateAsync, logout: logoutMutation.mutateAsync, isLoginPending: loginMutation.isPending, + isTotpPending: totpVerifyMutation.isPending, isSetupPending: setupMutation.isPending, }; } diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index 10da235..6ce11b3 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -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({ mutationFn: async ({ oldPin, newPin }: { oldPin: string; newPin: string }) => { const { data } = await api.put('/settings/pin', { old_pin: oldPin, new_pin: newPin }); @@ -34,8 +37,9 @@ export function useSettings() { settings: settingsQuery.data, isLoading: settingsQuery.isLoading, updateSettings: updateMutation.mutateAsync, - changePin: changePinMutation.mutateAsync, isUpdating: updateMutation.isPending, + // @deprecated — remove when SettingsPage is rewritten in Phase 3 + changePin: changePinMutation.mutateAsync, isChangingPin: changePinMutation.isPending, }; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7bd7268..7ec14b2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -7,6 +7,18 @@ export interface Settings { weather_lat?: number | null; weather_lon?: number | null; 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; updated_at: string; } @@ -177,6 +189,26 @@ export interface AuthStatus { 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 { todays_events: Array<{ id: number;