refactor: standardize import quotes and improve cookie management in auth storage

This commit is contained in:
Estifo77
2026-07-22 14:46:38 +03:00
parent a76549b018
commit 7191bc6791
3 changed files with 109 additions and 73 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState } from "react";
import { import {
Alert, Alert,
Anchor, Anchor,
@@ -11,29 +11,35 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
} from '@mantine/core'; } from "@mantine/core";
import { import {
IconArrowRight, IconArrowRight,
IconDeviceMobile, IconDeviceMobile,
IconLock, IconLock,
IconMail, IconMail,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod'; import { z } from "zod";
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from "react-router-dom";
import { useDispatch } from 'react-redux'; import { useDispatch } from "react-redux";
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from "@ema-platform/api";
import { notify } from '@ema-platform/ui'; import { notify } from "@ema-platform/ui";
import { AuthShell } from '../components/AuthShell'; import { AuthShell } from "../components/AuthShell";
import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice'; import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types'; import type {
import { useAuthConfig } from '../AuthConfig'; LoginPayload,
import { authStorage } from '../utils/auth-storage'; AuthUser,
CurrentProfile,
} from "../types/auth.types";
import { useAuthConfig } from "../AuthConfig";
import { authStorage } from "../utils/auth-storage";
const schema = z.object({ const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }), email: z.string().email({ message: "Enter a valid email" }),
password: z.string().min(5, { message: 'Password must be at least 6 characters' }), password: z
.string()
.min(5, { message: "Password must be at least 6 characters" }),
}); });
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
@@ -48,7 +54,10 @@ export function LoginPage() {
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const [loginTrigger] = useApiMutation<LoginPayload>(); const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>(); const [meTrigger] = useApiMutation<AuthUser>();
const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>(); const [profileCheckTrigger] = useApiMutation<{
total: number;
items: CurrentProfile[];
}>();
const { const {
register, register,
@@ -62,15 +71,15 @@ export function LoginPage() {
setIsLoading(true); setIsLoading(true);
try { try {
const data = await loginTrigger({ const data = await loginTrigger({
url: '/auth/login', url: "/auth/login",
method: 'POST', method: "POST",
body: values, body: values,
}).unwrap(); }).unwrap();
dispatch(loginSuccess(data)); dispatch(loginSuccess(data));
const me = await meTrigger({ const me = await meTrigger({
url: '/auth/me', url: "/auth/me",
method: 'GET', method: "GET",
}).unwrap(); }).unwrap();
dispatch(setUser(me)); dispatch(setUser(me));
@@ -79,7 +88,7 @@ export function LoginPage() {
const q = `w=user_id:=:${me.id}&i=user,address,profession`; const q = `w=user_id:=:${me.id}&i=user,address,profession`;
const result = await profileCheckTrigger({ const result = await profileCheckTrigger({
url: `/profiles?q=${encodeURIComponent(q)}`, url: `/profiles?q=${encodeURIComponent(q)}`,
method: 'GET', method: "GET",
}).unwrap(); }).unwrap();
if (result.total > 0 && result.items.length > 0) { if (result.total > 0 && result.items.length > 0) {
const profile = result.items[0]; const profile = result.items[0];
@@ -92,7 +101,7 @@ export function LoginPage() {
} }
if (!me.isPhoneNumberVerified) { if (!me.isPhoneNumberVerified) {
navigate('/otp-verify', { navigate("/otp-verify", {
state: { state: {
email: me.email, email: me.email,
phoneNumber: me.phoneNumber, phoneNumber: me.phoneNumber,
@@ -103,7 +112,7 @@ export function LoginPage() {
} }
if (!hasProfile) { if (!hasProfile) {
navigate('/profile-setup'); navigate("/profile-setup");
return; return;
} }
@@ -111,10 +120,11 @@ export function LoginPage() {
} catch (err: unknown) { } catch (err: unknown) {
const msg = const msg =
(err as { data?: { message?: string } })?.data?.message ?? (err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong'); (err instanceof Error ? err.message : "Something went wrong");
setServerError(msg); setServerError(msg);
notify.error(msg); notify.error(msg);
} finally { } finally {
localStorage.setItem("rememberMe", String(rememberMe));
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -132,7 +142,12 @@ export function LoginPage() {
</div> </div>
{serverError && ( {serverError && (
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}> <Alert
variant="light"
color="red"
withCloseButton
onClose={() => setServerError(null)}
>
{serverError} {serverError}
</Alert> </Alert>
)} )}
@@ -145,7 +160,7 @@ export function LoginPage() {
size="md" size="md"
leftSection={<IconMail size={18} />} leftSection={<IconMail size={18} />}
error={errors.email?.message} error={errors.email?.message}
{...register('email')} {...register("email")}
/> />
<PasswordInput <PasswordInput
label="Password" label="Password"
@@ -153,7 +168,7 @@ export function LoginPage() {
size="md" size="md"
leftSection={<IconLock size={18} />} leftSection={<IconLock size={18} />}
error={errors.password?.message} error={errors.password?.message}
{...register('password')} {...register("password")}
/> />
<Group justify="space-between"> <Group justify="space-between">
@@ -194,14 +209,14 @@ export function LoginPage() {
fullWidth fullWidth
size="md" size="md"
leftSection={<IconDeviceMobile size={18} />} leftSection={<IconDeviceMobile size={18} />}
onClick={() => notify.info('Phone sign-in is coming soon.')} onClick={() => notify.info("Phone sign-in is coming soon.")}
> >
Sign in with phone number Sign in with phone number
</Button> </Button>
{enableSignup && ( {enableSignup && (
<Text ta="center" size="sm" c="dimmed"> <Text ta="center" size="sm" c="dimmed">
Don&apos;t have an account?{' '} Don&apos;t have an account?{" "}
<Anchor component={Link} to="/signup" fw={700}> <Anchor component={Link} to="/signup" fw={700}>
Create one Create one
</Anchor> </Anchor>

View File

@@ -1,6 +1,11 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { AuthState, AuthUser, CurrentProfile, LoginPayload } from '../types/auth.types'; import type {
import { authStorage, clearAllCookies } from '../utils/auth-storage'; AuthState,
AuthUser,
CurrentProfile,
LoginPayload,
} from "../types/auth.types";
import { authStorage } from "../utils/auth-storage";
const initialState: AuthState = { const initialState: AuthState = {
user: null, user: null,
@@ -10,7 +15,7 @@ const initialState: AuthState = {
}; };
const authSlice = createSlice({ const authSlice = createSlice({
name: 'auth', name: "auth",
initialState, initialState,
reducers: { reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) { loginSuccess(state, action: PayloadAction<LoginPayload>) {
@@ -37,7 +42,7 @@ const authSlice = createSlice({
state.isAuthenticated = false; state.isAuthenticated = false;
state.currentProfile = null; state.currentProfile = null;
authStorage.clear(); authStorage.clear();
clearAllCookies() authStorage.clear();
}, },
hydrateAuth(state) { hydrateAuth(state) {
const token = authStorage.getToken(); const token = authStorage.getToken();
@@ -55,5 +60,12 @@ const authSlice = createSlice({
}, },
}); });
export const { loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } = authSlice.actions; export const {
loginSuccess,
setUser,
setCurrentProfile,
clearCurrentProfile,
logout,
hydrateAuth,
} = authSlice.actions;
export const authReducer = authSlice.reducer; export const authReducer = authSlice.reducer;

View File

@@ -1,35 +1,45 @@
import Cookies from 'js-cookie'; import Cookies from "js-cookie";
const rememberMe = localStorage.getItem("rememberMe") === "true";
const COOKIE_EXPIRES_DAYS = 7; // ponytail: cookie lifetime knob; JWT expiry + refresh govern real auth const COOKIE_EXPIRES_DAYS = rememberMe ? 15 : 1;
interface Backend { interface Backend {
get(k: string): string | null; get(k: string): string | null;
set(k: string, v: string): void; set(k: string, v: string): void;
remove(k: string): void; remove(k: string): void;
} }
const localBackend: Backend = { const localStore: Backend = {
get: (k) => localStorage.getItem(k), get: (k) => localStorage.getItem(k),
set: (k, v) => localStorage.setItem(k, v), set: (k, v) => localStorage.setItem(k, v),
remove: (k) => localStorage.removeItem(k), remove: (k) => localStorage.removeItem(k),
}; };
const cookieBackend: Backend = { const cookieStore: Backend = {
get: (k) => Cookies.get(k) ?? null, get: (k) => Cookies.get(k) ?? null,
// ponytail: 4KB/cookie cap — auth-user/current-profile JSON must stay under it
set: (k, v) => set: (k, v) =>
Cookies.set(k, v, { path: '/', secure: true, sameSite: 'strict', expires: COOKIE_EXPIRES_DAYS }), Cookies.set(k, v, {
remove: (k) => Cookies.remove(k, { path: '/' }), path: "/",
secure: true,
sameSite: "strict",
expires: COOKIE_EXPIRES_DAYS,
}),
remove: (k) => Cookies.remove(k, { path: "/" }),
}; };
const AUTH_KEY_NAMES = ['auth-token', 'refresh-token', 'auth-user', 'profile-id', 'current-profile']; const AUTH_KEY_NAMES = [
"auth-token",
"refresh-token",
"auth-user",
"profile-id",
"current-profile",
];
let _prefix = 'ema-auth'; let _prefix = "ema-auth";
let backend: Backend = localBackend; let backend: Backend = localStore;
export function configureAuthStorage(prefix: string, useCookies = false) { export function configureAuthStorage(prefix: string, useCookies = false) {
_prefix = prefix; _prefix = prefix;
backend = useCookies ? cookieBackend : localBackend; backend = useCookies ? cookieStore : localStore;
if (useCookies) { if (useCookies) {
// one-time cleanup: drop pre-migration localStorage leftovers so tokens live in cookies only // one-time cleanup: drop pre-migration localStorage leftovers so tokens live in cookies only
AUTH_KEY_NAMES.forEach((k) => localStorage.removeItem(`${prefix}-${k}`)); AUTH_KEY_NAMES.forEach((k) => localStorage.removeItem(`${prefix}-${k}`));
@@ -41,41 +51,40 @@ function key(k: string) {
} }
export const authStorage = { export const authStorage = {
getToken: () => backend.get(key('auth-token')) ?? undefined, getToken: () => backend.get(key("auth-token")) ?? undefined,
setToken: (token: string) => backend.set(key('auth-token'), token), setToken: (token: string) => backend.set(key("auth-token"), token),
getRefreshToken: () => backend.get(key('refresh-token')) ?? undefined, getRefreshToken: () => backend.get(key("refresh-token")) ?? undefined,
setRefreshToken: (t: string) => backend.set(key('refresh-token'), t), setRefreshToken: (t: string) => backend.set(key("refresh-token"), t),
getUser: <T = unknown>(): T | null => { getUser: <T = unknown>(): T | null => {
try { try {
return JSON.parse(backend.get(key('auth-user')) ?? 'null') as T | null; return JSON.parse(backend.get(key("auth-user")) ?? "null") as T | null;
} catch { } catch {
return null; return null;
} }
}, },
setUser: <T>(u: T) => backend.set(key('auth-user'), JSON.stringify(u)), setUser: <T>(u: T) => backend.set(key("auth-user"), JSON.stringify(u)),
getProfileId: () => backend.get(key('profile-id')) ?? undefined, getProfileId: () => backend.get(key("profile-id")) ?? undefined,
setProfileId: (id: string) => backend.set(key('profile-id'), id), setProfileId: (id: string) => backend.set(key("profile-id"), id),
getProfile: <T = unknown>(): T | null => { getProfile: <T = unknown>(): T | null => {
try { try {
return JSON.parse(backend.get(key('current-profile')) ?? 'null') as T | null; return JSON.parse(
backend.get(key("current-profile")) ?? "null",
) as T | null;
} catch { } catch {
return null; return null;
} }
}, },
setProfile: <T>(p: T) => backend.set(key('current-profile'), JSON.stringify(p)), setProfile: <T>(p: T) =>
removeProfile: () => backend.remove(key('current-profile')), backend.set(key("current-profile"), JSON.stringify(p)),
removeProfile: () => backend.remove(key("current-profile")),
clear: () => { clear: () => {
[key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id'), key('current-profile')].forEach((k) => [
backend.remove(k), key("auth-token"),
); key("refresh-token"),
Cookies.remove('auth-token', { path: '/' }); key("auth-user"),
key("profile-id"),
key("current-profile"),
].forEach((k) => backend.remove(k));
Cookies.remove("auth-token", { path: "/" });
}, },
}; };
export const clearAllCookies = () => {
const cookies = Cookies.get();
Object.keys(cookies).forEach((name) => {
Cookies.remove(name);
Cookies.remove(name, { path: '/' });
});
};