feat(ops-web/auth): add remember-me with session/local storage split

Persistent logins land in localStorage, ephemeral ones in sessionStorage,
and we recall the last username + remember preference on the login form.
Also propagate the indigo brand color through the antd ConfigProvider
theme token so the side nav and avatar match the redesigned login.
This commit is contained in:
2026-04-29 15:59:23 +08:00
parent 2d66e32938
commit a5cef078f5
6 changed files with 133 additions and 40 deletions
+10 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<a-config-provider> <a-config-provider :theme="themeConfig">
<a-app> <a-app>
<router-view /> <router-view />
</a-app> </a-app>
@@ -13,6 +13,15 @@ import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore(); const auth = useAuthStore();
const themeConfig = {
token: {
colorPrimary: "#4f46e5",
colorLink: "#4f46e5",
colorInfo: "#4f46e5",
borderRadius: 6,
},
};
onMounted(() => { onMounted(() => {
if (!auth.initialized) auth.hydrate(); if (!auth.initialized) auth.hydrate();
}); });
+4 -4
View File
@@ -138,14 +138,14 @@ onMounted(() => {
.ops-brand { .ops-brand {
height: 40px; height: 40px;
background: #f0f8ff; background: #eef2ff;
border: 1px solid #bae0ff; border: 1px solid #c7d2fe;
margin: 16px; margin: 16px;
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #1677ff; color: #4f46e5;
font-weight: 800; font-weight: 800;
font-size: 16px; font-size: 16px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -195,7 +195,7 @@ onMounted(() => {
} }
.user-avatar { .user-avatar {
background-color: #1677ff; background-color: #4f46e5;
color: #fff; color: #fff;
width: 28px; width: 28px;
height: 28px; height: 28px;
+1 -1
View File
@@ -44,7 +44,7 @@ const client: AxiosInstance = axios.create({
}); });
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
const session = readStoredSession(); const { session } = readStoredSession();
if (session.accessToken) { if (session.accessToken) {
config.headers = config.headers ?? {}; config.headers = config.headers ?? {};
config.headers["Authorization"] = `Bearer ${session.accessToken}`; config.headers["Authorization"] = `Bearer ${session.accessToken}`;
+77 -17
View File
@@ -1,4 +1,5 @@
const STORAGE_KEY = "geo.ops-web.session"; const SESSION_KEY = "geo.ops-web.session";
const PREFERENCE_KEY = "geo.ops-web.login-preference";
export interface OperatorView { export interface OperatorView {
id: number; id: number;
@@ -15,22 +16,36 @@ export interface OpsSession {
operator: OperatorView | null; operator: OperatorView | null;
} }
export interface LoginPreference {
remember: boolean;
lastUsername: string;
}
export interface StoredSession {
session: OpsSession;
persistent: boolean;
}
function emptySession(): OpsSession { function emptySession(): OpsSession {
return { accessToken: null, expiresAt: null, operator: null }; return { accessToken: null, expiresAt: null, operator: null };
} }
function hasStorage(): boolean { function emptyPreference(): LoginPreference {
return typeof window !== "undefined" && "localStorage" in window; return { remember: false, lastUsername: "" };
} }
export function readStoredSession(): OpsSession { function hasWindow(): boolean {
if (!hasStorage()) return emptySession(); return typeof window !== "undefined";
}
function readFrom(storage: Storage): OpsSession | null {
try { try {
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = storage.getItem(SESSION_KEY);
if (!raw) return emptySession(); if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<OpsSession>; const parsed = JSON.parse(raw) as Partial<OpsSession>;
if (typeof parsed.accessToken !== "string" || !parsed.accessToken) return null;
return { return {
accessToken: typeof parsed.accessToken === "string" ? parsed.accessToken : null, accessToken: parsed.accessToken,
expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null, expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null,
operator: operator:
parsed.operator && typeof parsed.operator === "object" parsed.operator && typeof parsed.operator === "object"
@@ -38,23 +53,68 @@ export function readStoredSession(): OpsSession {
: null, : null,
}; };
} catch { } catch {
return emptySession(); return null;
} }
} }
export function writeStoredSession(session: OpsSession): void { export function readStoredSession(): StoredSession {
if (!hasStorage()) return; if (!hasWindow()) return { session: emptySession(), persistent: false };
const persistent = readFrom(window.localStorage);
if (persistent) return { session: persistent, persistent: true };
const ephemeral = readFrom(window.sessionStorage);
if (ephemeral) return { session: ephemeral, persistent: false };
return { session: emptySession(), persistent: false };
}
export function writeStoredSession(session: OpsSession, persistent: boolean): void {
if (!hasWindow()) return;
const target = persistent ? window.localStorage : window.sessionStorage;
const other = persistent ? window.sessionStorage : window.localStorage;
try { try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); target.setItem(SESSION_KEY, JSON.stringify(session));
} catch { } catch {
// ignore quota / serialization errors // ignore quota / serialization errors
} }
}
export function clearStoredSession(): void {
if (!hasStorage()) return;
try { try {
window.localStorage.removeItem(STORAGE_KEY); other.removeItem(SESSION_KEY);
} catch {
// noop
}
}
export function clearStoredSession(): void {
if (!hasWindow()) return;
try {
window.localStorage.removeItem(SESSION_KEY);
} catch {
// noop
}
try {
window.sessionStorage.removeItem(SESSION_KEY);
} catch {
// noop
}
}
export function readLoginPreference(): LoginPreference {
if (!hasWindow()) return emptyPreference();
try {
const raw = window.localStorage.getItem(PREFERENCE_KEY);
if (!raw) return emptyPreference();
const parsed = JSON.parse(raw) as Partial<LoginPreference>;
return {
remember: parsed.remember === true,
lastUsername: typeof parsed.lastUsername === "string" ? parsed.lastUsername : "",
};
} catch {
return emptyPreference();
}
}
export function writeLoginPreference(pref: LoginPreference): void {
if (!hasWindow()) return;
try {
window.localStorage.setItem(PREFERENCE_KEY, JSON.stringify(pref));
} catch { } catch {
// noop // noop
} }
+23 -8
View File
@@ -5,7 +5,9 @@ import { http } from "@/lib/http";
import { import {
type OperatorView, type OperatorView,
clearStoredSession, clearStoredSession,
readLoginPreference,
readStoredSession, readStoredSession,
writeLoginPreference,
writeStoredSession, writeStoredSession,
} from "@/lib/storage"; } from "@/lib/storage";
@@ -20,25 +22,32 @@ export const useAuthStore = defineStore("ops-auth", () => {
const expiresAt = ref<number | null>(null); const expiresAt = ref<number | null>(null);
const operator = ref<OperatorView | null>(null); const operator = ref<OperatorView | null>(null);
const initialized = ref(false); const initialized = ref(false);
const persistent = ref(false);
function hydrate(): void { function hydrate(): void {
const stored = readStoredSession(); const stored = readStoredSession();
accessToken.value = stored.accessToken; accessToken.value = stored.session.accessToken;
expiresAt.value = stored.expiresAt; expiresAt.value = stored.session.expiresAt;
operator.value = stored.operator; operator.value = stored.session.operator;
persistent.value = stored.persistent;
initialized.value = true; initialized.value = true;
} }
async function login(username: string, password: string): Promise<void> { async function login(username: string, password: string, remember: boolean): Promise<void> {
const result = await http.post<LoginResponse>("/auth/login", { username, password }); const result = await http.post<LoginResponse>("/auth/login", { username, password });
accessToken.value = result.access_token; accessToken.value = result.access_token;
expiresAt.value = result.expires_at; expiresAt.value = result.expires_at;
operator.value = result.operator; operator.value = result.operator;
writeStoredSession({ persistent.value = remember;
writeStoredSession(
{
accessToken: result.access_token, accessToken: result.access_token,
expiresAt: result.expires_at, expiresAt: result.expires_at,
operator: result.operator, operator: result.operator,
}); },
remember,
);
writeLoginPreference({ remember, lastUsername: username });
} }
async function refreshSelf(): Promise<void> { async function refreshSelf(): Promise<void> {
@@ -53,11 +62,14 @@ export const useAuthStore = defineStore("ops-auth", () => {
role: me.role, role: me.role,
status: me.status, status: me.status,
}; };
writeStoredSession({ writeStoredSession(
{
accessToken: accessToken.value, accessToken: accessToken.value,
expiresAt: expiresAt.value, expiresAt: expiresAt.value,
operator: operator.value, operator: operator.value,
}); },
persistent.value,
);
} catch { } catch {
logout(); logout();
} }
@@ -67,6 +79,7 @@ export const useAuthStore = defineStore("ops-auth", () => {
accessToken.value = null; accessToken.value = null;
expiresAt.value = null; expiresAt.value = null;
operator.value = null; operator.value = null;
persistent.value = false;
clearStoredSession(); clearStoredSession();
} }
@@ -78,9 +91,11 @@ export const useAuthStore = defineStore("ops-auth", () => {
operator, operator,
initialized, initialized,
isAuthenticated, isAuthenticated,
persistent,
hydrate, hydrate,
login, login,
refreshSelf, refreshSelf,
logout, logout,
readLoginPreference,
}; };
}); });
+12 -3
View File
@@ -72,11 +72,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { StarOutlined, EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons-vue"; import { StarOutlined, EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons-vue";
import { OpsApiError } from "@/lib/http"; import { OpsApiError } from "@/lib/http";
import { readLoginPreference } from "@/lib/storage";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import AnimatedCharacters from "@/components/login-animation/AnimatedCharacters.vue"; import AnimatedCharacters from "@/components/login-animation/AnimatedCharacters.vue";
@@ -89,7 +90,7 @@ const primaryColor = ref('#4f46e5');
const submitting = ref(false); const submitting = ref(false);
const showPassword = ref(false); const showPassword = ref(false);
const isTyping = ref(false); const isTyping = ref(false);
const remember = ref(false); const remember = ref(true);
const errorMessage = ref<string | null>(null); const errorMessage = ref<string | null>(null);
const formState = reactive({ const formState = reactive({
@@ -97,6 +98,14 @@ const formState = reactive({
password: "", password: "",
}); });
onMounted(() => {
const pref = readLoginPreference();
remember.value = pref.remember;
if (pref.lastUsername) {
formState.username = pref.lastUsername;
}
});
async function handleSubmit(): Promise<void> { async function handleSubmit(): Promise<void> {
if (submitting.value) { if (submitting.value) {
return; return;
@@ -111,7 +120,7 @@ async function handleSubmit(): Promise<void> {
submitting.value = true; submitting.value = true;
try { try {
await authStore.login(formState.username.trim(), formState.password); await authStore.login(formState.username.trim(), formState.password, remember.value);
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/admin-users"; const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/admin-users";
await router.replace(redirect); await router.replace(redirect);
} catch (error) { } catch (error) {