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 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<string | null>(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<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;
|
||||
},
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user