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:
parent
5feb67bf13
commit
7f0ae0b6ef
43
frontend/src/components/ui/switch.tsx
Normal file
43
frontend/src/components/ui/switch.tsx
Normal 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 };
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user