Files
dumpsterChat/web/src/stores/auth.ts
T
hobokenchicken 08e5d92059 fix: handle 401 gracefully on web; add Bearer token auth for Tauri
- fetchMe() no longer surfaces 401 as a user-facing error (it just
  means 'no session', not a failure)
- API client auto-clears auth state on 401 mid-session so the user
  gets redirected to login instead of seeing 'ERR: Request failed: 401'
- Session middleware now accepts Authorization: Bearer <token> header
  as fallback when no cookie is present (for Tauri/native clients)
- Login, register, and WebAuthn endpoints expose X-Session-Token header
  so non-browser clients can capture the token
2026-07-16 14:46:17 -04:00

253 lines
6.7 KiB
TypeScript

import { create } from "zustand";
import { api } from "../lib/api.ts";
export type UserStatus = "online" | "idle" | "dnd" | "offline";
export interface User {
id: string;
username: string;
display_name: string;
email: string;
avatar: string;
bio: string;
accent_color: string;
status: UserStatus;
status_text: string;
created_at: string;
banner_url?: string;
tagline?: string;
pronouns?: string;
social_links?: { platform: string; url: string }[];
}
export interface PublicProfile extends User {
badges: { id: string; name: string; icon: string; description?: string; server_id?: string }[];
}
export type BlockedUser = {
id: string;
username: string;
created_at: string;
};
export interface UpdateProfilePayload {
avatar_url?: string;
display_name?: string;
bio?: string;
accent_color?: string;
status_text?: string;
status?: UserStatus;
banner_url?: string;
tagline?: string;
pronouns?: string;
social_links?: { platform: string; url: string }[];
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (identifier: string, password: string) => Promise<void>;
register: (
email: string,
username: string,
password: string,
displayName?: string,
) => Promise<void>;
logout: () => Promise<void>;
fetchMe: () => Promise<void>;
updateProfile: (data: UpdateProfilePayload) => Promise<void>;
changePassword: (
currentPassword: string,
newPassword: string,
) => Promise<void>;
requestPasswordReset: (email: string) => Promise<void>;
resetPassword: (token: string, newPassword: string) => Promise<void>;
getPublicProfile: (userId: string) => Promise<PublicProfile>;
listBlocks: () => Promise<BlockedUser[]>;
blockUser: (userId: string) => Promise<void>;
unblockUser: (userId: string) => Promise<void>;
clearError: () => void;
}
// ponytail: auto-subscribe to push notifications on auth
// Only fires when permission is already 'granted' (re-subscribe on session restore).
// iOS requires a user gesture for the initial permission prompt — that's handled by NotificationPrompt.
function autoSubscribePush() {
import("../stores/push.ts").then(({ usePushStore }) => {
const ps = usePushStore.getState();
if (ps.isSupported && Notification.permission === 'granted' && !ps.isSubscribed) {
ps.subscribe();
}
});
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (identifier, password) => {
set({ isLoading: true, error: null });
try {
await api.post("/auth/login/password", {
email: identifier,
password,
});
const user = await api.get<User>("/auth/me");
set({ user, isAuthenticated: true, isLoading: false });
autoSubscribePush();
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : "Login failed",
});
throw error;
}
},
register: async (email, username, password, displayName) => {
set({ isLoading: true, error: null });
try {
await api.post("/auth/register", {
email,
username,
password,
display_name: displayName || username,
});
const user = await api.get<User>("/auth/me");
set({ user, isAuthenticated: true, isLoading: false });
autoSubscribePush();
} catch (error) {
set({
isLoading: false,
error:
error instanceof Error ? error.message : "Registration failed",
});
throw error;
}
},
logout: async () => {
set({ isLoading: true, error: null });
try {
await api.post("/auth/logout");
set({ user: null, isAuthenticated: false, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : "Logout failed",
});
throw error;
}
},
fetchMe: async () => {
set({ isLoading: true, error: null });
try {
const user = await api.get<User>(`/auth/me?t=${Date.now()}`);
set({ user, isAuthenticated: true, isLoading: false });
autoSubscribePush();
} catch (error) {
// 401 from /auth/me simply means "no active session" — not a
// user-facing error. Only surface non-auth failures.
const isAuthError =
error instanceof Error && (error as import('../lib/api.ts').ApiError).status === 401;
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: isAuthError
? null
: error instanceof Error
? error.message
: "Failed to fetch user",
});
}
},
updateProfile: async (data) => {
set({ isLoading: true, error: null });
try {
const user = await api.patch<User>("/auth/me", data);
set({ user, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : "Update failed",
});
throw error;
}
},
changePassword: async (currentPassword, newPassword) => {
set({ isLoading: true, error: null });
try {
await api.put("/auth/me/password", {
current_password: currentPassword,
new_password: newPassword,
});
set({ isLoading: false });
} catch (error) {
set({
isLoading: false,
error:
error instanceof Error
? error.message
: "Password change failed",
});
throw error;
}
},
requestPasswordReset: async (email) => {
set({ isLoading: true, error: null });
try {
await api.post("/auth/request-password-reset", { email });
set({ isLoading: false });
} catch (error) {
set({
isLoading: false,
error:
error instanceof Error
? error.message
: "Failed to request password reset",
});
throw error;
}
},
resetPassword: async (token, newPassword) => {
set({ isLoading: true, error: null });
try {
await api.post("/auth/reset-password", { token, new_password: newPassword });
set({ isLoading: false });
} catch (error) {
set({
isLoading: false,
error:
error instanceof Error
? error.message
: "Failed to reset password",
});
throw error;
}
},
clearError: () => set({ error: null }),
getPublicProfile: async (userId) => api.get<PublicProfile>(`/users/${userId}/profile`),
listBlocks: async () => api.get<BlockedUser[]>('/users/me/blocks'),
blockUser: async (userId) => {
await api.post('/users/me/blocks', { user_id: userId });
},
unblockUser: async (userId) => {
await api.delete(`/users/me/blocks/${userId}`);
},
}));