Files
dumpsterChat/web/src/stores/auth.ts
T
hobokenchicken e80334e336 fix: iOS push notification prompt
iOS requires user gesture for Notification.requestPermission().
- Added NotificationPrompt banner that shows on permission='default'
- autoSubscribePush only fires when already 'granted' (re-subscribe on restore)
- Banner uses tap handler for permission request (works on iOS)
- Gruvbox themed, dismissible, bottom-center toast
2026-07-02 15:04:31 -04:00

246 lines
6.4 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) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error:
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}`);
},
}));