First commit
This commit is contained in:
158
frontend/app/composables/useAuth.ts
Normal file
158
frontend/app/composables/useAuth.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/* Who is using the wiki right now.
|
||||
|
||||
A session is a bearer token the API handed us, kept in a cookie rather than
|
||||
localStorage so the server can see it too: the sidebar then knows who is
|
||||
signed in on the very first draw, before any script runs. The token itself is
|
||||
never decoded here — the API is asked who it belongs to (see restoreSession). */
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
username: string
|
||||
/** Profile picture as a data URL; "" when the person has not chosen one. */
|
||||
avatar: string
|
||||
/** An admin opens the account dashboard; a member reads and writes. */
|
||||
role: AuthRole
|
||||
/** The interface language saved on the account; "" follows the browser. */
|
||||
locale: string
|
||||
}
|
||||
|
||||
export type AuthRole = 'admin' | 'member'
|
||||
|
||||
interface Session {
|
||||
token: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
const TOKEN_COOKIE = 'mathew-session'
|
||||
const WEEK_IN_SECONDS = 60 * 60 * 24 * 7
|
||||
|
||||
export function useAuth() {
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
|
||||
const token = useCookie<string | null>(TOKEN_COOKIE, {
|
||||
default: () => null,
|
||||
maxAge: WEEK_IN_SECONDS,
|
||||
sameSite: 'lax',
|
||||
})
|
||||
const user = useState<AuthUser | null>('auth:user', () => null)
|
||||
|
||||
const loggedIn = computed(() => user.value !== null)
|
||||
|
||||
/* Whether this session may open the admin dashboard. The API checks the
|
||||
account again on every admin request — this only decides what to draw. */
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
/* Header for the write endpoints. The read endpoints are public and ignore it. */
|
||||
function authHeaders(): Record<string, string> {
|
||||
return token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||
}
|
||||
|
||||
function openSession(session: Session) {
|
||||
token.value = session.token
|
||||
user.value = session.user
|
||||
}
|
||||
|
||||
/* Signing out is local: the token simply stops being sent, and it lapses on
|
||||
its own — the API issues nothing that needs revoking. */
|
||||
function signOut() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
}
|
||||
|
||||
async function signIn(username: string, password: string) {
|
||||
const session = await $fetch<Session>(`${api}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: { username: username.trim().toLowerCase(), password },
|
||||
})
|
||||
openSession(session)
|
||||
return session.user
|
||||
}
|
||||
|
||||
/* Opening an account signs you straight into it: the API answers with the
|
||||
same token pair as a login would. */
|
||||
async function signUp(username: string, password: string) {
|
||||
const session = await $fetch<Session>(`${api}/auth/register`, {
|
||||
method: 'POST',
|
||||
body: { username: username.trim().toLowerCase(), password },
|
||||
})
|
||||
openSession(session)
|
||||
return session.user
|
||||
}
|
||||
|
||||
/* Runs once at startup, on the server and in the browser: asks the API who a
|
||||
stored token belongs to. A token the API refuses — expired, or the account
|
||||
is gone — is dropped here rather than at the first save. A request that
|
||||
never got through (the backend is restarting) leaves it in place. */
|
||||
async function restoreSession() {
|
||||
if (!token.value || user.value) return
|
||||
try {
|
||||
const { user: known } = await $fetch<{ user: AuthUser }>(`${api}/auth/me`, {
|
||||
headers: authHeaders(),
|
||||
})
|
||||
user.value = known
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 401 || err?.response?.status === 401) token.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/* The account updates from the profile popup. Each comes back with the
|
||||
account as the API now has it, so the sidebar and the popup are showing
|
||||
the truth the moment the request resolves. */
|
||||
async function updateUsername(username: string) {
|
||||
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/me`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: { username: username.trim().toLowerCase() },
|
||||
})
|
||||
user.value = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
async function changePassword(currentPassword: string, newPassword: string) {
|
||||
await $fetch(`${api}/auth/password`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: { currentPassword, newPassword },
|
||||
})
|
||||
}
|
||||
|
||||
/* The picture arrives already shrunk to a small data URL (see ProfileModal);
|
||||
"" clears it and leaves the initial in its place. */
|
||||
async function setAvatar(dataUrl: string) {
|
||||
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/avatar`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: { avatar: dataUrl },
|
||||
})
|
||||
user.value = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
/* The interface language saved on the account, so the choice follows the
|
||||
person across devices. Switching this device's language is the caller's
|
||||
business (useAppLocale) — this only stores it. */
|
||||
async function updateLocale(locale: string) {
|
||||
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/locale`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: { locale },
|
||||
})
|
||||
user.value = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loggedIn,
|
||||
isAdmin,
|
||||
authHeaders,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
restoreSession,
|
||||
updateUsername,
|
||||
updateLocale,
|
||||
changePassword,
|
||||
setAvatar,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user