refactor: DifferencePage 컴포넌트에 로그인 링크 추가 및 상태 관리 개선
This commit is contained in:
parent
d19bb520ed
commit
6f02f21c7c
4 changed files with 123 additions and 82 deletions
|
@ -4,16 +4,18 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { useDifferenceDoc, commit, commitAll } from "@/hook/useDifference";
|
import { useDifferenceDoc, commit, commitAll } from "@/hook/useDifference";
|
||||||
import { useLogin } from "@/state/user";
|
import { useLogin } from "@/state/user";
|
||||||
import { Fragment } from "react/jsx-runtime";
|
import { Fragment } from "react/jsx-runtime";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
|
||||||
export function DifferencePage() {
|
export function DifferencePage() {
|
||||||
const { data, isLoading, error } = useDifferenceDoc();
|
const { data, isLoading, error } = useDifferenceDoc();
|
||||||
const userInfo = useLogin();
|
const userInfo = useLogin();
|
||||||
|
|
||||||
if (!userInfo) {
|
if (!userInfo) {
|
||||||
return <div className="p-4">
|
return <div className="p-4 w-full flex flex-col items-center">
|
||||||
<h2 className="text-3xl">
|
<h2 className="text-3xl">
|
||||||
Not logged in
|
Not logged in
|
||||||
</h2>
|
</h2>
|
||||||
|
<Link className="text-primary underline" href="/login">Go to Login</Link>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,7 +28,7 @@ export function DifferencePage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="relative">
|
<CardHeader className="relative">
|
||||||
<Button className="absolute right-2 top-8" variant="ghost"
|
<Button className="absolute right-2 top-8" variant="ghost"
|
||||||
onClick={() => {commitAll("comic")}}
|
onClick={() => { commitAll("comic") }}
|
||||||
>Commit All</Button>
|
>Commit All</Button>
|
||||||
<CardTitle className="text-2xl">Difference</CardTitle>
|
<CardTitle className="text-2xl">Difference</CardTitle>
|
||||||
<CardDescription>Scanned Files List</CardDescription>
|
<CardDescription>Scanned Files List</CardDescription>
|
||||||
|
@ -41,11 +43,11 @@ export function DifferencePage() {
|
||||||
{x.map((y) => (
|
{x.map((y) => (
|
||||||
<div key={y.path} className="flex items-center mt-2">
|
<div key={y.path} className="flex items-center mt-2">
|
||||||
<p
|
<p
|
||||||
className="flex-1 text-sm text-wrap">{y.path}</p>
|
className="flex-1 text-sm text-wrap">{y.path}</p>
|
||||||
<Button
|
<Button
|
||||||
className="flex-none ml-2"
|
className="flex-none ml-2"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {commit(y.path, y.type)}}>
|
onClick={() => { commit(y.path, y.type) }}>
|
||||||
Commit
|
Commit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,7 +14,7 @@ export type LogoutResponse = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RefreshResponse = LoginResponse & {
|
export type RefreshResponse = LoginResponse & {
|
||||||
refresh: boolean;
|
refresh: true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ErrorFormat = {
|
export type ErrorFormat = {
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { atom, useAtomValue } from "../lib/atom.ts";
|
|
||||||
import { createStore } from 'jotai';
|
|
||||||
import { LoginRequest } from "dbtype/mod.ts";
|
import { LoginRequest } from "dbtype/mod.ts";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
@ -9,41 +7,63 @@ import {
|
||||||
refreshService,
|
refreshService,
|
||||||
resetPasswordService,
|
resetPasswordService,
|
||||||
} from "./api.ts";
|
} from "./api.ts";
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
// Create a store for setting atom values outside components
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
let localObj: LoginResponse | null = null;
|
let localObj: LoginResponse | null = null;
|
||||||
|
|
||||||
function getUserSessions() {
|
function getUserSessions() {
|
||||||
if (localObj === null) {
|
if (!localObj) {
|
||||||
const storagestr = localStorage.getItem("UserLoginContext") as string | null;
|
const storagestr = localStorage.getItem("UserLoginContext");
|
||||||
const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginResponse | null) : null;
|
const storage = storagestr ? (JSON.parse(storagestr) as LoginResponse | null) : null;
|
||||||
|
// update localObj from storage
|
||||||
localObj = storage;
|
localObj = storage;
|
||||||
}
|
}
|
||||||
if (localObj !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) {
|
if (localObj && localObj.accessExpired > Math.floor(Date.now())) {
|
||||||
return {
|
return {
|
||||||
username: localObj.username,
|
username: localObj.username,
|
||||||
permission: localObj.permission,
|
permission: localObj.permission,
|
||||||
};
|
accessExpired: localObj.accessExpired,
|
||||||
}
|
};
|
||||||
return null;
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function setUserSessions(user: LoginResponse | null) {
|
||||||
|
localObj = user;
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem("UserLoginContext", JSON.stringify(user));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("UserLoginContext");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthEvent extends CustomEvent<{
|
||||||
|
type: "login" | "logout" | "refresh";
|
||||||
|
user: { username: string; permission: string[] } | null;
|
||||||
|
}> {
|
||||||
|
constructor(type: "login" | "logout" | "refresh", user: { username: string; permission: string[] } | null) {
|
||||||
|
super("auth", { detail: { type, user } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface WindowEventMap {
|
||||||
|
auth: AuthEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEventListener(
|
||||||
|
type: "auth",
|
||||||
|
listener: (this: Window, ev: AuthEvent) => void,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): void;
|
||||||
|
function removeEventListener(
|
||||||
|
type: "auth", listener: (this: Window, ev: AuthEvent) => void, options?: boolean | EventListenerOptions): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
try {
|
try {
|
||||||
const r = await refreshService();
|
const r = await refreshService();
|
||||||
if (r.refresh) {
|
setUserSessions(r);
|
||||||
localObj = {
|
window.dispatchEvent(new AuthEvent("refresh", { username: r.username, permission: r.permission }));
|
||||||
...r,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
localObj = {
|
|
||||||
accessExpired: 0,
|
|
||||||
username: "",
|
|
||||||
permission: r.permission,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
|
||||||
return {
|
return {
|
||||||
username: r.username,
|
username: r.username,
|
||||||
permission: r.permission,
|
permission: r.permission,
|
||||||
|
@ -52,51 +72,49 @@ export async function refresh() {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
console.error(`Refresh failed: ${e.detail}`);
|
console.error(`Refresh failed: ${e.detail}`);
|
||||||
}
|
}
|
||||||
localObj = { accessExpired: 0, username: "", permission: [] };
|
setUserSessions(null);
|
||||||
localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
window.dispatchEvent(new AuthEvent("logout", null));
|
||||||
return { username: "", permission: [] };
|
return {
|
||||||
|
username: "",
|
||||||
|
permission: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const doLogout = async () => {
|
export const doLogout = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await logoutService();
|
const res = await logoutService();
|
||||||
localObj = {
|
setUserSessions(null);
|
||||||
accessExpired: 0,
|
window.dispatchEvent(new AuthEvent("logout", null));
|
||||||
username: "",
|
return {
|
||||||
|
username: res.username,
|
||||||
permission: res.permission,
|
permission: res.permission,
|
||||||
};
|
};
|
||||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
|
||||||
store.set(userLoginStateAtom, localObj);
|
|
||||||
return {
|
|
||||||
username: localObj.username,
|
|
||||||
permission: localObj.permission,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Server Error ${error}`);
|
console.error(`Server Error ${error}`);
|
||||||
// Even if logout fails, clear client-side session
|
setUserSessions(null);
|
||||||
localObj = { accessExpired: 0, username: "", permission: [] };
|
return { username: "", permission: [] };
|
||||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
|
||||||
store.set(userLoginStateAtom, localObj);
|
|
||||||
return {
|
|
||||||
username: "",
|
|
||||||
permission: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const doLogin = async (userLoginInfo: LoginRequest): Promise<string | LoginResponse> => {
|
export const doLogin = async (userLoginInfo: LoginRequest): Promise<{
|
||||||
|
ok: true;
|
||||||
|
data: LoginResponse;
|
||||||
|
} | {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
}> => {
|
||||||
try {
|
try {
|
||||||
const b = await loginService(userLoginInfo);
|
const b = await loginService(userLoginInfo);
|
||||||
localObj = b;
|
setUserSessions(b);
|
||||||
store.set(userLoginStateAtom, b);
|
window.dispatchEvent(new AuthEvent("login", { username: b.username, permission: b.permission }));
|
||||||
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
|
return { ok: true, data: b };
|
||||||
return b;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
return e.detail ?? e.message;
|
return { ok: false, error: e.detail ?? e.message };
|
||||||
}
|
}
|
||||||
return "An unknown error occurred.";
|
return { ok: false, error: "An unknown error occurred." };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -111,23 +129,40 @@ export const doResetPassword = async (username: string, oldpassword: string, new
|
||||||
return await resetPasswordService(username, oldpassword, newpassword);
|
return await resetPasswordService(username, oldpassword, newpassword);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
return e.detail ?? e.message;
|
return { ok: false as const, error: e.detail ?? e.message };
|
||||||
}
|
}
|
||||||
return "An unknown error occurred.";
|
return { ok: false as const, error: "An unknown error occurred." };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getInitialValue() {
|
|
||||||
const user = getUserSessions();
|
|
||||||
if (user) {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
return refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const userLoginStateAtom = atom(getUserSessions());
|
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const val = useAtomValue(userLoginStateAtom);
|
const hook = useSyncExternalStore(
|
||||||
return val;
|
(onChange) => {
|
||||||
}
|
const listener = () => {
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
window.addEventListener("auth", listener);
|
||||||
|
return () => window.removeEventListener("auth", listener);
|
||||||
|
},
|
||||||
|
getUserSessions,
|
||||||
|
);
|
||||||
|
return hook;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", async () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
const session = getUserSessions();
|
||||||
|
if (!session) {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
if (session && session.accessExpired - Date.now() < 5 * 60 * 1000) {
|
||||||
|
// access token will expire in 5 minutes, refresh it
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the session is still valid, do nothing
|
||||||
|
}
|
||||||
|
});
|
|
@ -25,7 +25,7 @@ type LoginResponse = {
|
||||||
|
|
||||||
type RefreshResponse = {
|
type RefreshResponse = {
|
||||||
accessExpired: number;
|
accessExpired: number;
|
||||||
refresh: boolean;
|
refresh: true;
|
||||||
} & PayloadInfo;
|
} & PayloadInfo;
|
||||||
|
|
||||||
type RefreshPayloadInfo = { username: string };
|
type RefreshPayloadInfo = { username: string };
|
||||||
|
@ -45,8 +45,8 @@ const ResetBodySchema = t.Object({
|
||||||
|
|
||||||
const SettingsBodySchema = t.Record(t.String(), t.Unknown());
|
const SettingsBodySchema = t.Record(t.String(), t.Unknown());
|
||||||
|
|
||||||
const accessExpiredTime = 60 * 60 * 2; // 2 hours
|
const accessExpiredTime = 60 * 60 * 2 * 1000; // 2 hours
|
||||||
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 days
|
const refreshExpiredTime = 60 * 60 * 24 * 14 * 1000; // 14 days
|
||||||
|
|
||||||
async function createAccessToken(payload: PayloadInfo, secret: string) {
|
async function createAccessToken(payload: PayloadInfo, secret: string) {
|
||||||
return await new SignJWT(payload)
|
return await new SignJWT(payload)
|
||||||
|
@ -83,7 +83,7 @@ async function verifyToken<T>(token: string, secret: string): Promise<T> {
|
||||||
export const accessTokenName = "access_token";
|
export const accessTokenName = "access_token";
|
||||||
export const refreshTokenName = "refresh_token";
|
export const refreshTokenName = "refresh_token";
|
||||||
|
|
||||||
function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredSeconds: number) {
|
function setToken(cookie: CookieJar, token_name: string, token_payload: string | null, expiredMilliseconds: number) {
|
||||||
if (token_payload === null) {
|
if (token_payload === null) {
|
||||||
cookie[token_name]?.remove();
|
cookie[token_name]?.remove();
|
||||||
return;
|
return;
|
||||||
|
@ -94,10 +94,14 @@ function setToken(cookie: CookieJar, token_name: string, token_payload: string |
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: setting.secure,
|
secure: setting.secure,
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
expires: new Date(Date.now() + expiredSeconds * 1000),
|
expires: new Date(Date.now() + expiredMilliseconds),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeToken(cookie: CookieJar, token_name: string) {
|
||||||
|
cookie[token_name]?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
const isUserState = (obj: unknown): obj is PayloadInfo => {
|
const isUserState = (obj: unknown): obj is PayloadInfo => {
|
||||||
if (typeof obj !== "object" || obj === null) {
|
if (typeof obj !== "object" || obj === null) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -237,15 +241,15 @@ export const createLoginRouter = (userController: UserAccessor) => {
|
||||||
return {
|
return {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
permission,
|
permission,
|
||||||
accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
|
accessExpired: Math.floor(Date.now() ) + accessExpiredTime,
|
||||||
} satisfies LoginResponse;
|
} satisfies LoginResponse;
|
||||||
}, {
|
}, {
|
||||||
body: LoginBodySchema,
|
body: LoginBodySchema,
|
||||||
})
|
})
|
||||||
.post("/logout", ({ cookie, set }) => {
|
.post("/logout", ({ cookie, set }) => {
|
||||||
const setting = get_setting();
|
const setting = get_setting();
|
||||||
setToken(cookie, accessTokenName, null, 0);
|
removeToken(cookie, accessTokenName);
|
||||||
setToken(cookie, refreshTokenName, null, 0);
|
removeToken(cookie, refreshTokenName);
|
||||||
set.status = 200;
|
set.status = 200;
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
@ -261,7 +265,7 @@ export const createLoginRouter = (userController: UserAccessor) => {
|
||||||
return {
|
return {
|
||||||
...auth.user,
|
...auth.user,
|
||||||
refresh: true,
|
refresh: true,
|
||||||
accessExpired: Math.floor(Date.now() / 1000) + accessExpiredTime,
|
accessExpired: Math.floor(Date.now()) + accessExpiredTime,
|
||||||
} satisfies RefreshResponse;
|
} satisfies RefreshResponse;
|
||||||
})
|
})
|
||||||
.post("/reset", async ({ body }) => {
|
.post("/reset", async ({ body }) => {
|
||||||
|
|
Loading…
Add table
Reference in a new issue