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>
<a-config-provider>
<a-config-provider :theme="themeConfig">
<a-app>
<router-view />
</a-app>
@@ -13,6 +13,15 @@ import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const themeConfig = {
token: {
colorPrimary: "#4f46e5",
colorLink: "#4f46e5",
colorInfo: "#4f46e5",
borderRadius: 6,
},
};
onMounted(() => {
if (!auth.initialized) auth.hydrate();
});
+4 -4
View File
@@ -138,14 +138,14 @@ onMounted(() => {
.ops-brand {
height: 40px;
background: #f0f8ff;
border: 1px solid #bae0ff;
background: #eef2ff;
border: 1px solid #c7d2fe;
margin: 16px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #1677ff;
color: #4f46e5;
font-weight: 800;
font-size: 16px;
letter-spacing: 0.05em;
@@ -195,7 +195,7 @@ onMounted(() => {
}
.user-avatar {
background-color: #1677ff;
background-color: #4f46e5;
color: #fff;
width: 28px;
height: 28px;
+1 -1
View File
@@ -44,7 +44,7 @@ const client: AxiosInstance = axios.create({
});
client.interceptors.request.use((config) => {
const session = readStoredSession();
const { session } = readStoredSession();
if (session.accessToken) {
config.headers = config.headers ?? {};
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 {
id: number;
@@ -15,22 +16,36 @@ export interface OpsSession {
operator: OperatorView | null;
}
export interface LoginPreference {
remember: boolean;
lastUsername: string;
}
export interface StoredSession {
session: OpsSession;
persistent: boolean;
}
function emptySession(): OpsSession {
return { accessToken: null, expiresAt: null, operator: null };
}
function hasStorage(): boolean {
return typeof window !== "undefined" && "localStorage" in window;
function emptyPreference(): LoginPreference {
return { remember: false, lastUsername: "" };
}
export function readStoredSession(): OpsSession {
if (!hasStorage()) return emptySession();
function hasWindow(): boolean {
return typeof window !== "undefined";
}
function readFrom(storage: Storage): OpsSession | null {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return emptySession();
const raw = storage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<OpsSession>;
if (typeof parsed.accessToken !== "string" || !parsed.accessToken) return null;
return {
accessToken: typeof parsed.accessToken === "string" ? parsed.accessToken : null,
accessToken: parsed.accessToken,
expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : null,
operator:
parsed.operator && typeof parsed.operator === "object"
@@ -38,23 +53,68 @@ export function readStoredSession(): OpsSession {
: null,
};
} catch {
return emptySession();
return null;
}
}
export function writeStoredSession(session: OpsSession): void {
if (!hasStorage()) return;
export function readStoredSession(): StoredSession {
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 {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
target.setItem(SESSION_KEY, JSON.stringify(session));
} catch {
// ignore quota / serialization errors
}
}
export function clearStoredSession(): void {
if (!hasStorage()) return;
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 {
// noop
}
+29 -14
View File
@@ -5,7 +5,9 @@ import { http } from "@/lib/http";
import {
type OperatorView,
clearStoredSession,
readLoginPreference,
readStoredSession,
writeLoginPreference,
writeStoredSession,
} from "@/lib/storage";
@@ -20,25 +22,32 @@ export const useAuthStore = defineStore("ops-auth", () => {
const expiresAt = ref<number | null>(null);
const operator = ref<OperatorView | null>(null);
const initialized = ref(false);
const persistent = ref(false);
function hydrate(): void {
const stored = readStoredSession();
accessToken.value = stored.accessToken;
expiresAt.value = stored.expiresAt;
operator.value = stored.operator;
accessToken.value = stored.session.accessToken;
expiresAt.value = stored.session.expiresAt;
operator.value = stored.session.operator;
persistent.value = stored.persistent;
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 });
accessToken.value = result.access_token;
expiresAt.value = result.expires_at;
operator.value = result.operator;
writeStoredSession({
accessToken: result.access_token,
expiresAt: result.expires_at,
operator: result.operator,
});
persistent.value = remember;
writeStoredSession(
{
accessToken: result.access_token,
expiresAt: result.expires_at,
operator: result.operator,
},
remember,
);
writeLoginPreference({ remember, lastUsername: username });
}
async function refreshSelf(): Promise<void> {
@@ -53,11 +62,14 @@ export const useAuthStore = defineStore("ops-auth", () => {
role: me.role,
status: me.status,
};
writeStoredSession({
accessToken: accessToken.value,
expiresAt: expiresAt.value,
operator: operator.value,
});
writeStoredSession(
{
accessToken: accessToken.value,
expiresAt: expiresAt.value,
operator: operator.value,
},
persistent.value,
);
} catch {
logout();
}
@@ -67,6 +79,7 @@ export const useAuthStore = defineStore("ops-auth", () => {
accessToken.value = null;
expiresAt.value = null;
operator.value = null;
persistent.value = false;
clearStoredSession();
}
@@ -78,9 +91,11 @@ export const useAuthStore = defineStore("ops-auth", () => {
operator,
initialized,
isAuthenticated,
persistent,
hydrate,
login,
refreshSelf,
logout,
readLoginPreference,
};
});
+12 -3
View File
@@ -72,11 +72,12 @@
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { StarOutlined, EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons-vue";
import { OpsApiError } from "@/lib/http";
import { readLoginPreference } from "@/lib/storage";
import { useAuthStore } from "@/stores/auth";
import AnimatedCharacters from "@/components/login-animation/AnimatedCharacters.vue";
@@ -89,7 +90,7 @@ const primaryColor = ref('#4f46e5');
const submitting = ref(false);
const showPassword = ref(false);
const isTyping = ref(false);
const remember = ref(false);
const remember = ref(true);
const errorMessage = ref<string | null>(null);
const formState = reactive({
@@ -97,6 +98,14 @@ const formState = reactive({
password: "",
});
onMounted(() => {
const pref = readLoginPreference();
remember.value = pref.remember;
if (pref.lastUsername) {
formState.username = pref.lastUsername;
}
});
async function handleSubmit(): Promise<void> {
if (submitting.value) {
return;
@@ -111,7 +120,7 @@ async function handleSubmit(): Promise<void> {
submitting.value = true;
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";
await router.replace(redirect);
} catch (error) {