Compare commits

...

12 Commits

14 changed files with 295 additions and 162 deletions

View File

@ -198,7 +198,7 @@ func (h *OAuthHandler) tokenEndpoint(w http.ResponseWriter, r *http.Request) {
Nonce: nonce,
Roles: []string{"user", "admin"},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://cb5f-2a00-10-5b00-c801-e955-5c68-63d0-b777.ngrok-free.app",
Issuer: h.cfg.Jwt.Issuer,
// TODO: use dedicated API id that is in local DB and bind to user there
Subject: user.ID.String(),
Audience: jwt.ClaimStrings{clientId},

16
web/package-lock.json generated
View File

@ -17,6 +17,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-icons": "^5.5.0",
"react-jwt": "^1.3.0",
"react-router": "^7.6.0",
"tailwindcss": "^4.1.7",
"zustand": "^5.0.5"
@ -4353,6 +4354,21 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-jwt": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/react-jwt/-/react-jwt-1.3.0.tgz",
"integrity": "sha512-aC+X6q8pi63zoO7A060/4mfF5jM6Ay+4YyY4QgdD8dDOqp89sPcg0IhWEHyPACnVETMjBWzmxMPgIPosQNeYyw==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"optionalDependencies": {
"fsevents": "^2.3.2"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@ -19,6 +19,7 @@
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-icons": "^5.5.0",
"react-jwt": "^1.3.0",
"react-router": "^7.6.0",
"tailwindcss": "^4.1.7",
"zustand": "^5.0.5"

View File

@ -4,8 +4,8 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import IndexPage from "./pages/Index";
import LoginPage from "./pages/Login";
import RegisterPage from "./pages/Register";
import AgreementPage from "./pages/Agreement";
import OAuthAuthorizePage from "./pages/OAuthAuthorize";
import AuthorizePage from "./pages/Authorize";
import AuthenticatePage from "./pages/Authenticate";
import AuthLayout from "./layout/AuthLayout";
const router = createBrowserRouter([
@ -14,10 +14,10 @@ const router = createBrowserRouter([
element: <AuthLayout />,
children: [
{ index: true, element: <IndexPage /> },
{ path: "agreement", element: <AgreementPage /> },
{ path: "authorize", element: <AuthorizePage /> },
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
{ path: "authorize", element: <OAuthAuthorizePage /> },
{ path: "authenticate", element: <AuthenticatePage /> },
],
},
]);

View File

@ -4,65 +4,100 @@ import { useAuth } from "@/store/auth";
import Axios, { type AxiosResponse } from "axios";
import { refreshTokenApi } from "./refresh";
import { isExpired } from "react-jwt";
export const axios = Axios.create({
headers: {
"Content-Type": "application/json",
},
});
let isRefreshing = false;
let refreshQueue: ((token: string | null) => void)[] = [];
const waitForTokenRefresh = () => {
return new Promise<string | null>((resolve) => {
refreshQueue.push((token: string | null) => resolve(token));
});
};
const processRefreshQueue = async (token: string | null) => {
refreshQueue.forEach((resolve) => resolve(token));
refreshQueue = [];
};
const refreshToken = async (
accountId: string,
refreshToken: string
): Promise<{ access: string; refresh: string }> => {
const db = useDbStore.getState().db;
const loadAccounts = useAuth.getState().loadAccounts;
const requireSignIn = useAuth.getState().requireSignIn;
if (!db) {
console.log("No database connection available.");
return Promise.reject("No database connection available.");
}
try {
const response = await refreshTokenApi(refreshToken);
await updateAccountTokens(db, {
accountId: accountId,
access: response.access,
refresh: response.refresh,
});
processRefreshQueue(response.access);
return { access: response.access, refresh: response.refresh };
} catch (err) {
console.error("Token refresh failed:", err);
await deleteAccount(db, accountId);
requireSignIn?.();
processRefreshQueue(null);
throw err;
} finally {
localStorage.removeItem("refreshing");
loadAccounts?.();
isRefreshing = false;
}
};
axios.interceptors.request.use(
(request) => {
async (request) => {
const account = useAuth.getState().activeAccount;
if (account?.access) {
request.headers["Authorization"] = `Bearer ${account.access}`;
let token: string | null = account?.access ?? null;
if (!token || !isExpired(token)) {
request.headers["Authorization"] = `Bearer ${token}`;
return request;
}
if (!isRefreshing) {
isRefreshing = true;
try {
const { access } = await refreshToken(
account!.accountId,
account!.refresh
);
token = access;
} catch (err) {
console.error("Token refresh failed:", err);
throw err;
}
} else {
token = await waitForTokenRefresh();
}
if (!token) {
throw new Error("No token available");
}
request.headers["Authorization"] = `Bearer ${token}`;
return request;
},
(error) => {
return Promise.reject(error);
}
);
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const account = useAuth.getState().activeAccount;
if (!account?.refresh) {
return Promise.reject(new Error("Unauthorized. No refresh token"));
}
const db = useDbStore.getState().db;
if (!db) {
return Promise.reject(new Error("No database connection"));
}
try {
const response = await refreshTokenApi(account.refresh);
updateAccountTokens(db, {
accountId: account.accountId,
access: response.access,
refresh: response.refresh,
});
} catch (err) {
console.error("token refresh failed:", err);
await deleteAccount(db, account.accountId);
const loadAccounts = useAuth.getState().loadAccounts;
loadAccounts?.();
const requireSignIn = useAuth.getState().requireSignIn;
requireSignIn?.();
return Promise.reject(err);
}
}
return Promise.reject(error);
}
(error) => Promise.reject(error)
);
export const handleApiError = async (response: AxiosResponse) => {

View File

@ -9,7 +9,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
className?: string;
loading?: boolean;
variant?: "contained" | "outlined" | "text";
variant?: "contained" | "outlined" | "text" | "icon";
}
export const Button: FC<ButtonProps> = ({
@ -22,11 +22,13 @@ export const Button: FC<ButtonProps> = ({
const appearance = useMemo(() => {
switch (variant) {
case "contained":
return "bg-blue-600 text-white hover:bg-blue-700";
return "px-4 py-2 bg-blue-600 text-white hover:bg-blue-700";
case "outlined":
return "border border-blue-600 text-blue-600 hover:text-blue-700 font-medium";
return "px-4 py-2 border border-blue-600 text-blue-600 hover:text-blue-700 font-medium";
case "text":
return "text-blue-600 hover:text-blue-700 font-medium";
return "py-2 px-4 text-blue-600 hover:text-blue-700 font-medium";
case "icon":
return "py-0 px-0 text-gray-400 hover:text-gray-600";
}
return "";
@ -34,7 +36,7 @@ export const Button: FC<ButtonProps> = ({
return (
<button
className={`cursor-pointer py-2 px-4 rounded-md transition-colors ${appearance} ${
className={`${appearance} cursor-pointer rounded-md transition-colors ${
className || ""
}${
loading

View File

@ -1,22 +1,20 @@
import { useOAuthContext } from "@/context/oauth";
import { type LocalAccount } from "@/repository/account";
import { useAuth } from "@/store/auth";
import { CirclePlus, User } from "lucide-react";
import { useCallback, type FC } from "react";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
const AccountList: FC = () => {
const accounts = useAuth((state) => state.accounts);
const updateActiveAccount = useAuth((state) => state.updateActiveAccount);
const oauth = useOAuthContext();
const location = useLocation();
const handleAccountSelect = useCallback(
(account: LocalAccount) => {
oauth.selectSession(account.access);
updateActiveAccount(account);
},
[oauth, updateActiveAccount]
[updateActiveAccount]
);
return (
@ -52,7 +50,7 @@ const AccountList: FC = () => {
</div>
</div>
))}
<Link to="/login">
<Link to="/login" state={location.state}>
<div className="flex flex-row items-center p-4 border-gray-200 dark:border-gray-700/65 border-b border-r-0 border-l-0 select-none cursor-pointer hover:bg-gray-50/50 dark:hover:bg-gray-800/10 transition-colors mb-0">
<div>
<div className="rounded-full p-2 text-gray-900 dark:text-gray-200 mr-3">

View File

@ -0,0 +1,30 @@
import { useAuth } from "@/store/auth";
import { User } from "lucide-react";
import type { FC } from "react";
export interface AvatarProps {
iconSize?: number;
className?: string;
}
const Avatar: FC<AvatarProps> = ({ iconSize = 32, className }) => {
const profile = useAuth((state) => state.profile);
return (
<div
className={`overflow-hidden bg-gray-100 rounded-full ring ring-gray-400 dark:ring dark:ring-gray-500 flex items-center justify-center ${className}`}
>
{profile?.profile_picture ? (
<img
src={profile?.profile_picture?.toString()}
className="w-full h-full flex-1 object-cover"
alt="profile"
/>
) : (
<User size={iconSize} />
)}
</div>
);
};
export default Avatar;

View File

@ -1,22 +1,43 @@
import { useDbStore } from "@/store/db";
import { useAuth } from "@/store/auth";
import { useEffect, useMemo } from "react";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
Navigate,
Outlet,
useLocation,
useNavigate,
useSearchParams,
} from "react-router-dom";
import BackgroundLayout from "./BackgroundLayout";
import { useOAuthContext } from "@/context/oauth";
const AuthLayout = () => {
const { connecting, db, connect } = useDbStore();
const [searchParams] = useSearchParams();
const {
active,
setActive,
setClientID,
setRedirectURI,
setScope,
setState,
setNonce,
} = useOAuthContext();
const dbConnected = useMemo(() => !!db, [db]);
const loadingAccounts = useAuth((state) => state.loadingAccounts);
const loadAccounts = useAuth((state) => state.loadAccounts);
const hasLoadedAccounts = useAuth((state) => state.hasLoadedAccounts);
const activeAccount = useAuth((state) => state.activeAccount);
const hasAccounts = useAuth((state) => state.accounts.length > 0);
const fetchingProfile = useAuth((state) => state.authenticating);
const fetchProfile = useAuth((state) => state.authenticate);
const authenticating = useAuth((state) => state.authenticating);
const authenticate = useAuth((state) => state.authenticate);
const hasAuthenticated = useAuth((state) => state.hasAuthenticated);
const signInRequired = useAuth((state) => state.signInRequired);
@ -24,28 +45,54 @@ const AuthLayout = () => {
const navigate = useNavigate();
const isAuthPage = useMemo(() => {
const allowedPaths = ["/login", "/register", "/authorize"];
if (!allowedPaths.some((p) => location.pathname.startsWith(p))) {
return false;
}
return true;
const allowedPaths = ["/login", "/register", "/authenticate"];
return allowedPaths.some((p) => location.pathname.startsWith(p));
}, [location.pathname]);
console.log({
isAuthPage,
loadingAccounts,
fetchingProfile,
connecting,
dbConnected,
});
const loading = useMemo(() => {
if (isAuthPage) {
return connecting;
}
return loadingAccounts || fetchingProfile || connecting;
}, [connecting, fetchingProfile, isAuthPage, loadingAccounts]);
return (
!hasAuthenticated ||
!hasLoadedAccounts ||
loadingAccounts ||
authenticating ||
connecting
);
}, [
isAuthPage,
hasAuthenticated,
hasLoadedAccounts,
loadingAccounts,
authenticating,
connecting,
]);
useEffect(() => {
if (!active) {
console.log(
"setting search params:",
Object.fromEntries(searchParams.entries())
);
setActive(true);
setClientID(searchParams.get("client_id") ?? "");
setRedirectURI(searchParams.get("redirect_uri") ?? "");
const scope = searchParams.get("scope") ?? "";
setScope(scope.split(" ").filter((s) => s.length > 0));
setState(searchParams.get("state") ?? "");
setNonce(searchParams.get("nonce") ?? "");
}
}, [
active,
searchParams,
setActive,
setClientID,
setNonce,
setRedirectURI,
setScope,
setState,
]);
useEffect(() => {
connect();
@ -59,20 +106,21 @@ const AuthLayout = () => {
useEffect(() => {
if (dbConnected && !loadingAccounts && activeAccount) {
fetchProfile();
authenticate();
}
}, [activeAccount, dbConnected, fetchProfile, loadingAccounts]);
}, [activeAccount, dbConnected, authenticate, loadingAccounts]);
useEffect(() => {
if (!signInRequired && location.state?.from) {
navigate(location.state.from, { state: {} });
if (!signInRequired && isAuthPage) {
const to = location.state?.from ?? "/";
navigate(to, { state: { reset: true } });
}
}, [location.state, location.state?.from, navigate, signInRequired]);
}, [isAuthPage, location.state?.from, navigate, signInRequired]);
if (signInRequired && !isAuthPage) {
return (
<Navigate
to={hasAccounts ? "/authorize" : "/login"}
to={hasAccounts ? "/authenticate" : "/login"}
state={{ from: location.pathname }}
/>
);

View File

@ -1,39 +1,8 @@
import { Card, CardContent } from "@/components/ui/card";
import { useOAuthContext } from "@/context/oauth";
import AccountList from "@/feature/AccountList";
import { useEffect, type FC } from "react";
import { useSearchParams } from "react-router-dom";
const OAuthAuthorizePage: FC = () => {
const [searchParams] = useSearchParams();
const {
setActive,
setClientID,
setRedirectURI,
setScope,
setState,
setNonce,
} = useOAuthContext();
useEffect(() => {
setActive(true);
setClientID(searchParams.get("client_id") ?? "");
setRedirectURI(searchParams.get("redirect_uri") ?? "");
const scope = searchParams.get("scope") ?? "";
setScope(scope.split(" ").filter((s) => s.length > 0));
setState(searchParams.get("state") ?? "");
setNonce(searchParams.get("nonce") ?? "");
}, [
searchParams,
setActive,
setClientID,
setNonce,
setRedirectURI,
setScope,
setState,
]);
import type { FC } from "react";
const AuthenticatePage: FC = () => {
return (
<div className="relative z-10 flex items-center justify-center min-h-screen">
<Card className="sm:w-[700px] sm:min-w-[700px] sm:max-w-96 sm:min-h-auto p-3 min-h-screen w-full min-w-full shadow-lg bg-white/65 dark:bg-black/65 backdrop-blur-md">
@ -66,4 +35,4 @@ const OAuthAuthorizePage: FC = () => {
);
};
export default OAuthAuthorizePage;
export default AuthenticatePage;

View File

@ -1,13 +1,25 @@
import { type FC } from "react";
import { useCallback, type FC } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeftRight } from "lucide-react";
import { ArrowLeftRight, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import Avatar from "@/feature/Avatar";
import { useAuth } from "@/store/auth";
import { useOAuthContext } from "@/context/oauth";
const AuthorizePage: FC = () => {
const promptAccountSelection = useAuth((state) => state.deleteActiveAccount);
const activeAccount = useAuth((state) => state.activeAccount);
const oauth = useOAuthContext();
const AgreementPage: FC = () => {
const profile = useAuth((state) => state.profile);
const handleAgree = useCallback(() => {
if (!activeAccount) return;
oauth.selectSession(activeAccount.access);
}, [activeAccount, oauth]);
return (
<div
className={`relative min-h-screen bg-cover bg-center bg-white dark:bg-black bg-[url(/overlay.jpg)] dark:bg-[url(/dark-overlay.jpg)]`}
@ -22,16 +34,8 @@ const AgreementPage: FC = () => {
className="w-16 h-16 mb-4 mt-2 sm:mt-6"
/> */}
<div className="flex flex-row items-center gap-4 mt-2 mb-4 sm:mt-6">
<div className="w-12 h-12 overflow-hidden bg-gray-100 rounded-full ring ring-gray-400 dark:ring dark:ring-gray-500">
{/* <User size={32} /> */}
<img
src={profile?.profile_picture?.toString()}
className="w-full h-full flex-1 object-cover"
alt="profile"
/>
</div>
<Avatar iconSize={32} className="w-12 h-12" />
<div className="text-gray-400 dark:text-gray-600">
{/* <Activity /> */}
<ArrowLeftRight />
</div>
<div className="p-2 rounded-full bg-gray-900 ring ring-gray-400 dark:ring dark:ring-gray-500">
@ -54,16 +58,17 @@ const AgreementPage: FC = () => {
wants to access your Home Account
</h2>
<div className="flex flex-row items-center justify-center mb-6 gap-2">
<div className="w-10 h-10 overflow-hidden bg-gray-100 rounded-full ring ring-gray-400 dark:ring dark:ring-gray-500">
<img
src="http://192.168.178.69:9000/guard-storage/profile_eff00028-2d9e-458d-8944-677855edc147_1748099702417601900.jpg"
className="w-full h-full flex-1"
alt="profile"
/>
</div>
<Avatar iconSize={28} className="w-9 h-9" />
<p className="text-sm text-gray-500 dark:text-gray-500">
qwer.009771@gmail.com
{profile?.email}
</p>
<Button
variant="icon"
className="px-0 py-0"
onClick={promptAccountSelection}
>
<ChevronDown />
</Button>
</div>
<h4 className="text-base mb-3 text-gray-400 dark:text-gray-500 text-left">
This will allow{" "}
@ -107,7 +112,7 @@ const AgreementPage: FC = () => {
<div className="flex flex-row justify-between items-center">
<Button variant="text">Cancel</Button>
<Button>Allow</Button>
<Button onClick={handleAgree}>Allow</Button>
</div>
</CardContent>
</div>
@ -117,4 +122,4 @@ const AgreementPage: FC = () => {
);
};
export default AgreementPage;
export default AuthorizePage;

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Card, CardContent } from "@/components/ui/card";
import { Mail, Lock } from "lucide-react";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -9,7 +9,6 @@ import { useForm, type SubmitHandler } from "react-hook-form";
import { useCallback, useState } from "react";
import { loginApi } from "@/api/login";
import { useAccountRepo } from "@/repository/account";
import { useOAuthContext } from "@/context/oauth";
import { useAuth } from "@/store/auth";
interface LoginForm {
@ -29,10 +28,10 @@ export default function LoginPage() {
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const oauth = useOAuthContext();
const repo = useAccountRepo();
const location = useLocation();
const updateActiveAccount = useAuth((state) => state.updateActiveAccount);
const onSubmit: SubmitHandler<LoginForm> = useCallback(
@ -63,8 +62,7 @@ export default function LoginPage() {
setSuccess("You have successfully logged in");
reset();
oauth.selectSession(response.access);
updateActiveAccount(account);
await updateActiveAccount(account);
} catch (err: any) {
console.log(err);
setError(
@ -75,7 +73,7 @@ export default function LoginPage() {
setLoading(false);
}
},
[oauth, repo, reset, updateActiveAccount]
[repo, reset, updateActiveAccount]
);
return (
@ -166,7 +164,11 @@ export default function LoginPage() {
</Button>
<div className="text-sm text-center text-gray-600">
Don't have an account?{" "}
<Link to="/register" className="text-blue-600 hover:underline">
<Link
to="/register"
state={location.state}
className="text-blue-600 hover:underline"
>
Register
</Link>
</div>

View File

@ -1,6 +1,6 @@
import { Card, CardContent } from "@/components/ui/card";
import { Mail, Lock, User, Phone } from "lucide-react";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -23,6 +23,8 @@ export default function RegisterPage() {
formState: { errors },
} = useForm<RegisterForm>();
const location = useLocation();
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
@ -238,7 +240,11 @@ export default function RegisterPage() {
</Button>
<div className="text-sm text-center text-gray-600">
Already have an account?{" "}
<Link to="/login" className="text-blue-600 hover:underline">
<Link
to="/login"
state={location.state}
className="text-blue-600 hover:underline"
>
Login
</Link>
</div>

View File

@ -17,9 +17,15 @@ export interface IAuthState {
accounts: LocalAccount[];
signInRequired: boolean;
hasAuthenticated: boolean;
hasLoadedAccounts: boolean;
loadAccounts: () => Promise<void>;
updateActiveAccount: (account: LocalAccount) => Promise<void>;
deleteActiveAccount: () => Promise<void>;
authenticate: () => Promise<void>;
requireSignIn: () => void;
signOut: () => void;
}
@ -47,6 +53,9 @@ export const useAuth = create<IAuthState>((set, get) => ({
loadingAccounts: false,
signInRequired: false,
hasAuthenticated: false,
hasLoadedAccounts: false,
updateActiveAccount: async (account) => {
set({ activeAccount: account });
@ -73,16 +82,28 @@ export const useAuth = create<IAuthState>((set, get) => ({
if (!active) {
set({ signInRequired: true });
} else {
const account = accounts.find((acc) => acc.accountId === active);
if (!account) {
resetActiveAccount();
set({ signInRequired: true });
} else {
set({ activeAccount: account });
}
}
const account = accounts.find((acc) => acc.accountId === active);
set({
accounts,
loadingAccounts: false,
hasLoadedAccounts: true,
});
},
if (!account) {
resetActiveAccount();
set({ signInRequired: true });
}
set({ activeAccount: account, accounts, loadingAccounts: false });
deleteActiveAccount: async () => {
resetActiveAccount();
set({ activeAccount: null });
get().loadAccounts();
},
authenticate: async () => {
@ -114,7 +135,7 @@ export const useAuth = create<IAuthState>((set, get) => ({
// TODO: set error
console.log(err);
} finally {
set({ authenticating: false });
set({ authenticating: false, hasAuthenticated: true });
}
},