From 5a6970426609072080e8e421e5949f369980e4f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:49:21 +0000 Subject: [PATCH 1/5] Initial plan From 7c2e3cd9c35f42ede6d199f52157db48a59f7a47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:58:12 +0000 Subject: [PATCH 2/5] feat(auth): create @object-ui/auth package with AuthProvider, useAuth, AuthGuard, forms, and token injection Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/auth/package.json | 42 ++++ packages/auth/src/AuthContext.ts | 38 ++++ packages/auth/src/AuthGuard.tsx | 66 ++++++ packages/auth/src/AuthProvider.tsx | 199 ++++++++++++++++++ packages/auth/src/ForgotPasswordForm.tsx | 135 ++++++++++++ packages/auth/src/LoginForm.tsx | 144 +++++++++++++ packages/auth/src/RegisterForm.tsx | 178 ++++++++++++++++ packages/auth/src/UserMenu.tsx | 111 ++++++++++ packages/auth/src/createAuthClient.ts | 97 +++++++++ packages/auth/src/createAuthenticatedFetch.ts | 55 +++++ packages/auth/src/index.ts | 46 ++++ packages/auth/src/types.ts | 106 ++++++++++ packages/auth/src/useAuth.ts | 46 ++++ packages/auth/tsconfig.json | 19 ++ pnpm-lock.yaml | 27 ++- 15 files changed, 1305 insertions(+), 4 deletions(-) create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/AuthContext.ts create mode 100644 packages/auth/src/AuthGuard.tsx create mode 100644 packages/auth/src/AuthProvider.tsx create mode 100644 packages/auth/src/ForgotPasswordForm.tsx create mode 100644 packages/auth/src/LoginForm.tsx create mode 100644 packages/auth/src/RegisterForm.tsx create mode 100644 packages/auth/src/UserMenu.tsx create mode 100644 packages/auth/src/createAuthClient.ts create mode 100644 packages/auth/src/createAuthenticatedFetch.ts create mode 100644 packages/auth/src/index.ts create mode 100644 packages/auth/src/types.ts create mode 100644 packages/auth/src/useAuth.ts create mode 100644 packages/auth/tsconfig.json diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 00000000..62b28a84 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,42 @@ +{ + "name": "@object-ui/auth", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "Authentication system for Object UI with AuthProvider, useAuth hook, AuthGuard, and form components.", + "homepage": "https://www.objectui.org", + "repository": { + "type": "git", + "url": "https://github.com/objectstack-ai/objectui.git", + "directory": "packages/auth" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "vitest run", + "type-check": "tsc --noEmit", + "lint": "eslint ." + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + }, + "dependencies": { + "@object-ui/types": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.13", + "react": "^19.1.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/packages/auth/src/AuthContext.ts b/packages/auth/src/AuthContext.ts new file mode 100644 index 00000000..b9aa849d --- /dev/null +++ b/packages/auth/src/AuthContext.ts @@ -0,0 +1,38 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { createContext } from 'react'; +import type { AuthUser, AuthSession } from './types'; + +export interface AuthContextValue { + /** Current authenticated user */ + user: AuthUser | null; + /** Current session information */ + session: AuthSession | null; + /** Whether the user is authenticated */ + isAuthenticated: boolean; + /** Whether auth state is loading */ + isLoading: boolean; + /** Authentication error */ + error: Error | null; + /** Sign in with email and password */ + signIn: (email: string, password: string) => Promise; + /** Sign up with name, email, and password */ + signUp: (name: string, email: string, password: string) => Promise; + /** Sign out the current user */ + signOut: () => Promise; + /** Update user profile */ + updateUser: (data: Partial) => Promise; + /** Request password reset */ + forgotPassword: (email: string) => Promise; + /** Reset password with token */ + resetPassword: (token: string, newPassword: string) => Promise; +} + +export const AuthCtx = createContext(null); +AuthCtx.displayName = 'AuthContext'; diff --git a/packages/auth/src/AuthGuard.tsx b/packages/auth/src/AuthGuard.tsx new file mode 100644 index 00000000..979bc553 --- /dev/null +++ b/packages/auth/src/AuthGuard.tsx @@ -0,0 +1,66 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { useAuth } from './useAuth'; + +export interface AuthGuardProps { + /** Content to render when user is not authenticated */ + fallback?: React.ReactNode; + /** Required roles (user must have at least one) */ + requiredRoles?: string[]; + /** Required permissions (user must have all) */ + requiredPermissions?: string[]; + /** Content to render when loading */ + loadingFallback?: React.ReactNode; + /** Children to render when authenticated */ + children: React.ReactNode; +} + +/** + * Route guard component that conditionally renders children + * based on authentication and authorization state. + * + * @example + * ```tsx + * }> + * + * + * + * }> + * + * + * ``` + */ +export function AuthGuard({ + fallback = null, + requiredRoles, + loadingFallback, + children, +}: AuthGuardProps) { + const { isAuthenticated, isLoading, user } = useAuth(); + + if (isLoading) { + return <>{loadingFallback ?? null}; + } + + if (!isAuthenticated) { + return <>{fallback}; + } + + // Check role requirements + if (requiredRoles && requiredRoles.length > 0 && user) { + const userRoles = user.roles ?? (user.role ? [user.role] : []); + const hasRole = requiredRoles.some((role) => userRoles.includes(role)); + if (!hasRole) { + return <>{fallback}; + } + } + + return <>{children}; +} diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx new file mode 100644 index 00000000..b5ec1af3 --- /dev/null +++ b/packages/auth/src/AuthProvider.tsx @@ -0,0 +1,199 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import type { AuthUser, AuthClient, AuthProviderConfig } from './types'; +import { AuthCtx, type AuthContextValue } from './AuthContext'; +import { createAuthClient } from './createAuthClient'; + +export interface AuthProviderProps extends AuthProviderConfig { + children: React.ReactNode; +} + +/** + * Authentication context provider. + * + * Wraps the application to provide authentication state and methods + * to all child components via the useAuth hook. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function AuthProvider({ + authUrl, + client: externalClient, + onAuthStateChange, + children, +}: AuthProviderProps) { + const client = useMemo( + () => externalClient ?? createAuthClient({ baseURL: authUrl }), + [externalClient, authUrl], + ); + + const [user, setUser] = useState(null); + const [session, setSession] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const isAuthenticated = user !== null && session !== null; + + // Load session on mount + useEffect(() => { + let cancelled = false; + + async function loadSession() { + try { + const result = await client.getSession(); + if (cancelled) return; + if (result) { + setUser(result.user); + setSession(result.session); + } + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + loadSession(); + return () => { cancelled = true; }; + }, [client]); + + // Notify on auth state changes + useEffect(() => { + onAuthStateChange?.({ + user, + session, + isAuthenticated, + isLoading, + error, + }); + }, [user, session, isAuthenticated, isLoading, error, onAuthStateChange]); + + const signIn = useCallback( + async (email: string, password: string) => { + setIsLoading(true); + setError(null); + try { + const result = await client.signIn({ email, password }); + setUser(result.user); + setSession(result.session); + } catch (err) { + const authError = err instanceof Error ? err : new Error(String(err)); + setError(authError); + throw authError; + } finally { + setIsLoading(false); + } + }, + [client], + ); + + const signUp = useCallback( + async (name: string, email: string, password: string) => { + setIsLoading(true); + setError(null); + try { + const result = await client.signUp({ name, email, password }); + setUser(result.user); + setSession(result.session); + } catch (err) { + const authError = err instanceof Error ? err : new Error(String(err)); + setError(authError); + throw authError; + } finally { + setIsLoading(false); + } + }, + [client], + ); + + const signOut = useCallback(async () => { + setIsLoading(true); + try { + await client.signOut(); + setUser(null); + setSession(null); + setError(null); + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setIsLoading(false); + } + }, [client]); + + const updateUser = useCallback( + async (data: Partial) => { + setError(null); + try { + const updated = await client.updateUser(data); + setUser(updated); + } catch (err) { + const authError = err instanceof Error ? err : new Error(String(err)); + setError(authError); + throw authError; + } + }, + [client], + ); + + const forgotPassword = useCallback( + async (email: string) => { + setError(null); + try { + await client.forgotPassword(email); + } catch (err) { + const authError = err instanceof Error ? err : new Error(String(err)); + setError(authError); + throw authError; + } + }, + [client], + ); + + const resetPassword = useCallback( + async (token: string, newPassword: string) => { + setError(null); + try { + await client.resetPassword(token, newPassword); + } catch (err) { + const authError = err instanceof Error ? err : new Error(String(err)); + setError(authError); + throw authError; + } + }, + [client], + ); + + const value = useMemo( + () => ({ + user, + session, + isAuthenticated, + isLoading, + error, + signIn, + signUp, + signOut, + updateUser, + forgotPassword, + resetPassword, + }), + [user, session, isAuthenticated, isLoading, error, signIn, signUp, signOut, updateUser, forgotPassword, resetPassword], + ); + + return {children}; +} diff --git a/packages/auth/src/ForgotPasswordForm.tsx b/packages/auth/src/ForgotPasswordForm.tsx new file mode 100644 index 00000000..4ca480a8 --- /dev/null +++ b/packages/auth/src/ForgotPasswordForm.tsx @@ -0,0 +1,135 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import { useAuth } from './useAuth'; + +export interface ForgotPasswordFormProps { + /** Callback on successful submission */ + onSuccess?: () => void; + /** Callback on error */ + onError?: (error: Error) => void; + /** Link to login page */ + loginUrl?: string; + /** Custom title */ + title?: string; + /** Custom description */ + description?: string; +} + +/** + * Forgot password form component. + * Sends a password reset email to the user. + * + * @example + * ```tsx + * setShowSuccess(true)} + * loginUrl="/login" + * /> + * ``` + */ +export function ForgotPasswordForm({ + onSuccess, + onError, + loginUrl = '/login', + title = 'Reset your password', + description = 'Enter your email address and we\'ll send you a link to reset your password', +}: ForgotPasswordFormProps) { + const { forgotPassword, isLoading } = useAuth(); + const [email, setEmail] = useState(''); + const [error, setError] = useState(null); + const [submitted, setSubmitted] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + try { + await forgotPassword(email); + setSubmitted(true); + onSuccess?.(); + } catch (err) { + const authError = err instanceof Error ? err : new Error(String(err)); + setError(authError.message); + onError?.(authError); + } + }; + + if (submitted) { + return ( +
+
+

Check your email

+

+ We've sent a password reset link to {email}. + Please check your inbox and follow the instructions. +

+
+ {loginUrl && ( +

+ + Back to sign in + +

+ )} +
+ ); + } + + return ( +
+
+

{title}

+

{description}

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + autoComplete="email" + disabled={isLoading} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ + +
+ + {loginUrl && ( +

+ Remember your password?{' '} + + Sign in + +

+ )} +
+ ); +} diff --git a/packages/auth/src/LoginForm.tsx b/packages/auth/src/LoginForm.tsx new file mode 100644 index 00000000..f725b262 --- /dev/null +++ b/packages/auth/src/LoginForm.tsx @@ -0,0 +1,144 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import { useAuth } from './useAuth'; + +export interface LoginFormProps { + /** Callback on successful login */ + onSuccess?: () => void; + /** Callback on login error */ + onError?: (error: Error) => void; + /** Link to registration page */ + registerUrl?: string; + /** Link to forgot password page */ + forgotPasswordUrl?: string; + /** Custom title */ + title?: string; + /** Custom description */ + description?: string; +} + +/** + * Login form component with email/password authentication. + * Uses Tailwind CSS utility classes for styling. + * + * @example + * ```tsx + * navigate('/dashboard')} + * registerUrl="/register" + * forgotPasswordUrl="/forgot-password" + * /> + * ``` + */ +export function LoginForm({ + onSuccess, + onError, + registerUrl = '/register', + forgotPasswordUrl = '/forgot-password', + title = 'Sign in to your account', + description = 'Enter your email and password to continue', +}: LoginFormProps) { + const { signIn, isLoading } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + try { + await signIn(email, password); + onSuccess?.(); + } catch (err) { + const authError = err instanceof Error ? err : new Error(String(err)); + setError(authError.message); + onError?.(authError); + } + }; + + return ( +
+
+

{title}

+

{description}

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + autoComplete="email" + disabled={isLoading} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+
+ + {forgotPasswordUrl && ( + + Forgot password? + + )} +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + disabled={isLoading} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ + +
+ + {registerUrl && ( +

+ Don't have an account?{' '} + + Sign up + +

+ )} +
+ ); +} diff --git a/packages/auth/src/RegisterForm.tsx b/packages/auth/src/RegisterForm.tsx new file mode 100644 index 00000000..eac9c8ec --- /dev/null +++ b/packages/auth/src/RegisterForm.tsx @@ -0,0 +1,178 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import { useAuth } from './useAuth'; + +export interface RegisterFormProps { + /** Callback on successful registration */ + onSuccess?: () => void; + /** Callback on registration error */ + onError?: (error: Error) => void; + /** Link to login page */ + loginUrl?: string; + /** Custom title */ + title?: string; + /** Custom description */ + description?: string; +} + +/** + * Registration form component with name, email, and password fields. + * Uses Tailwind CSS utility classes for styling. + * + * @example + * ```tsx + * navigate('/dashboard')} + * loginUrl="/login" + * /> + * ``` + */ +export function RegisterForm({ + onSuccess, + onError, + loginUrl = '/login', + title = 'Create an account', + description = 'Enter your information to get started', +}: RegisterFormProps) { + const { signUp, isLoading } = useAuth(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + try { + await signUp(name, email, password); + onSuccess?.(); + } catch (err) { + const authError = err instanceof Error ? err : new Error(String(err)); + setError(authError.message); + onError?.(authError); + } + }; + + return ( +
+
+

{title}

+

{description}

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setName(e.target.value)} + required + autoComplete="name" + disabled={isLoading} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + disabled={isLoading} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+ + setPassword(e.target.value)} + required + minLength={8} + autoComplete="new-password" + disabled={isLoading} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + autoComplete="new-password" + disabled={isLoading} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ + +
+ + {loginUrl && ( +

+ Already have an account?{' '} + + Sign in + +

+ )} +
+ ); +} diff --git a/packages/auth/src/UserMenu.tsx b/packages/auth/src/UserMenu.tsx new file mode 100644 index 00000000..87b3155b --- /dev/null +++ b/packages/auth/src/UserMenu.tsx @@ -0,0 +1,111 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { useAuth } from './useAuth'; + +export interface UserMenuProps { + /** Custom avatar URL override */ + avatarUrl?: string; + /** Callback for profile navigation */ + onProfile?: () => void; + /** Callback for settings navigation */ + onSettings?: () => void; + /** Custom menu items */ + children?: React.ReactNode; +} + +/** + * User menu component displaying the authenticated user's info. + * Shows avatar, name, email, and common actions (profile, settings, sign out). + * + * This is a headless component that provides the user data and actions. + * The actual dropdown rendering is handled by the consumer (e.g., AppSidebar). + * + * @example + * ```tsx + * navigate('/profile')} /> + * ``` + */ +export function UserMenu({ + avatarUrl, + onProfile, + onSettings, +}: UserMenuProps) { + const { user, signOut, isAuthenticated } = useAuth(); + + if (!isAuthenticated || !user) { + return null; + } + + const initials = user.name + ? user.name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) + : user.email?.[0]?.toUpperCase() ?? '?'; + + const imageUrl = avatarUrl ?? user.image; + + return ( +
+
+ {imageUrl ? ( + {user.name} + ) : ( + {initials} + )} +
+
+ {user.name} + {user.email} +
+ {(onProfile || onSettings) && ( +
+ {onProfile && ( + + )} + {onSettings && ( + + )} +
+ )} + +
+ ); +} diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts new file mode 100644 index 00000000..1e994553 --- /dev/null +++ b/packages/auth/src/createAuthClient.ts @@ -0,0 +1,97 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AuthClient, AuthClientConfig, AuthUser, AuthSession, SignInCredentials, SignUpData } from './types'; + +/** + * Create an auth client instance. + * + * This factory creates an abstraction layer over the authentication provider. + * It is designed to work with better-auth but can be adapted to any auth backend + * that exposes standard REST endpoints for sign-in, sign-up, sign-out, and session management. + * + * @example + * ```ts + * const authClient = createAuthClient({ baseURL: '/api/auth' }); + * const { user, session } = await authClient.signIn({ email, password }); + * ``` + */ +export function createAuthClient(config: AuthClientConfig): AuthClient { + const { baseURL, fetchFn = fetch } = config; + + async function request(path: string, options?: RequestInit): Promise { + const url = `${baseURL}${path}`; + const response = await fetchFn(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + credentials: 'include', + }); + + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.message || `Auth request failed: ${response.status}`); + } + + return response.json(); + } + + return { + async signIn(credentials: SignInCredentials) { + return request<{ user: AuthUser; session: AuthSession }>('/sign-in/email', { + method: 'POST', + body: JSON.stringify(credentials), + }); + }, + + async signUp(data: SignUpData) { + return request<{ user: AuthUser; session: AuthSession }>('/sign-up/email', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async signOut() { + await request('/sign-out', { method: 'POST' }); + }, + + async getSession() { + try { + return await request<{ user: AuthUser; session: AuthSession }>('/get-session', { + method: 'GET', + }); + } catch { + return null; + } + }, + + async forgotPassword(email: string) { + await request('/forgot-password', { + method: 'POST', + body: JSON.stringify({ email }), + }); + }, + + async resetPassword(token: string, newPassword: string) { + await request('/reset-password', { + method: 'POST', + body: JSON.stringify({ token, newPassword }), + }); + }, + + async updateUser(data: Partial) { + const result = await request<{ user: AuthUser }>('/update-user', { + method: 'POST', + body: JSON.stringify(data), + }); + return result.user; + }, + }; +} diff --git a/packages/auth/src/createAuthenticatedFetch.ts b/packages/auth/src/createAuthenticatedFetch.ts new file mode 100644 index 00000000..f8abbbf5 --- /dev/null +++ b/packages/auth/src/createAuthenticatedFetch.ts @@ -0,0 +1,55 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AuthClient } from './types'; + +/** + * Options for creating an authenticated adapter. + */ +export interface AuthenticatedAdapterOptions { + /** Base URL for the ObjectStack API */ + baseUrl: string; + /** Auth client to get session tokens from */ + authClient: AuthClient; + /** Additional adapter options */ + [key: string]: unknown; +} + +/** + * Creates an authenticated fetch wrapper that injects the Bearer token + * from the auth session into every request to the ObjectStack API. + * + * This is the recommended way to integrate authentication with + * @objectstack/client's ObjectStackAdapter. + * + * @example + * ```ts + * import { ObjectStackAdapter } from '@object-ui/data-objectstack'; + * import { createAuthClient, createAuthenticatedFetch } from '@object-ui/auth'; + * + * const authClient = createAuthClient({ baseURL: '/api/auth' }); + * const authenticatedFetch = createAuthenticatedFetch(authClient); + * + * const adapter = new ObjectStackAdapter({ + * baseUrl: '/api/v1', + * fetch: authenticatedFetch, + * }); + * ``` + */ +export function createAuthenticatedFetch( + authClient: AuthClient, +): (input: RequestInfo | URL, init?: RequestInit) => Promise { + return async (input: RequestInfo | URL, init?: RequestInit) => { + const session = await authClient.getSession(); + const headers = new Headers(init?.headers); + if (session?.session?.token) { + headers.set('Authorization', `Bearer ${session.session.token}`); + } + return fetch(input, { ...init, headers }); + }; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 00000000..dd165e87 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,46 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/auth + * + * Authentication system for Object UI providing: + * - AuthProvider context for React apps + * - useAuth hook for accessing auth state and methods + * - AuthGuard component for route protection + * - LoginForm, RegisterForm, ForgotPasswordForm UI components + * - UserMenu component for authenticated user display + * - createAuthClient factory for auth backend integration + * - createAuthenticatedFetch for DataSource token injection + * + * @packageDocumentation + */ + +export { AuthProvider, type AuthProviderProps } from './AuthProvider'; +export { useAuth } from './useAuth'; +export { AuthGuard, type AuthGuardProps } from './AuthGuard'; +export { LoginForm, type LoginFormProps } from './LoginForm'; +export { RegisterForm, type RegisterFormProps } from './RegisterForm'; +export { ForgotPasswordForm, type ForgotPasswordFormProps } from './ForgotPasswordForm'; +export { UserMenu, type UserMenuProps } from './UserMenu'; +export { createAuthClient } from './createAuthClient'; +export { createAuthenticatedFetch, type AuthenticatedAdapterOptions } from './createAuthenticatedFetch'; + +// Re-export types for convenience +export type { + AuthUser, + AuthSession, + AuthState, + AuthClient, + AuthClientConfig, + AuthProviderConfig, + SignInCredentials, + SignUpData, +} from './types'; + +export type { AuthContextValue } from './AuthContext'; diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts new file mode 100644 index 00000000..fe5aee6b --- /dev/null +++ b/packages/auth/src/types.ts @@ -0,0 +1,106 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Authentication types for @object-ui/auth + */ + +/** Authenticated user information */ +export interface AuthUser { + /** Unique user identifier */ + id: string; + /** Display name */ + name: string; + /** Email address */ + email: string; + /** Profile image URL */ + image?: string; + /** Primary role */ + role?: string; + /** All assigned roles */ + roles?: string[]; + /** Email verification status */ + emailVerified?: boolean; + /** Additional user metadata */ + [key: string]: unknown; +} + +/** Session information */ +export interface AuthSession { + /** Access token */ + token: string; + /** Token expiry timestamp */ + expiresAt?: Date; + /** Refresh token */ + refreshToken?: string; +} + +/** Authentication state */ +export interface AuthState { + /** Current authenticated user */ + user: AuthUser | null; + /** Current session */ + session: AuthSession | null; + /** Whether the user is authenticated */ + isAuthenticated: boolean; + /** Whether auth state is loading */ + isLoading: boolean; + /** Authentication error */ + error: Error | null; +} + +/** Sign in credentials */ +export interface SignInCredentials { + email: string; + password: string; +} + +/** Sign up data */ +export interface SignUpData { + name: string; + email: string; + password: string; +} + +/** Auth client configuration */ +export interface AuthClientConfig { + /** Authentication server URL (e.g., "/api/auth") */ + baseURL: string; + /** Custom fetch function for requests */ + fetchFn?: typeof fetch; +} + +/** Auth client interface - abstracts the underlying auth library */ +export interface AuthClient { + /** Sign in with email/password */ + signIn: (credentials: SignInCredentials) => Promise<{ user: AuthUser; session: AuthSession }>; + /** Sign up with email/password */ + signUp: (data: SignUpData) => Promise<{ user: AuthUser; session: AuthSession }>; + /** Sign out */ + signOut: () => Promise; + /** Get current session */ + getSession: () => Promise<{ user: AuthUser; session: AuthSession } | null>; + /** Reset password request */ + forgotPassword: (email: string) => Promise; + /** Reset password with token */ + resetPassword: (token: string, newPassword: string) => Promise; + /** Update user profile */ + updateUser: (data: Partial) => Promise; +} + +/** Auth provider configuration */ +export interface AuthProviderConfig { + /** Authentication server URL */ + authUrl: string; + /** Auth client instance (if already created) */ + client?: AuthClient; + /** Callback when auth state changes */ + onAuthStateChange?: (state: AuthState) => void; + /** Path to redirect to when not authenticated */ + redirectTo?: string; +} diff --git a/packages/auth/src/useAuth.ts b/packages/auth/src/useAuth.ts new file mode 100644 index 00000000..8b13419e --- /dev/null +++ b/packages/auth/src/useAuth.ts @@ -0,0 +1,46 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useContext } from 'react'; +import { AuthCtx, type AuthContextValue } from './AuthContext'; + +/** + * Hook to access authentication state and methods. + * Must be used within an AuthProvider. + * + * @example + * ```tsx + * function UserProfile() { + * const { user, isAuthenticated, signOut } = useAuth(); + * if (!isAuthenticated) return null; + * return {user.name}; + * } + * ``` + */ +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthCtx); + + if (!ctx) { + // Return a safe default when used outside AuthProvider + return { + user: null, + session: null, + isAuthenticated: false, + isLoading: false, + error: null, + signIn: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + signUp: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + signOut: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + updateUser: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + forgotPassword: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + resetPassword: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + }; + } + + return ctx; +} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 00000000..553f189a --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "noEmit": false, + "declaration": true, + "composite": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faf5039c..5ce6cc71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,6 +580,25 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/auth: + dependencies: + '@object-ui/types': + specifier: workspace:* + version: link:../types + devDependencies: + '@types/react': + specifier: 19.2.13 + version: 19.2.13 + react: + specifier: 19.2.4 + version: 19.2.4 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.2.2)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(tsx@4.21.0) + packages/cli: dependencies: '@object-ui/components': @@ -15178,14 +15197,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': + '@vitest/mocker@4.0.18(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.9(@types/node@25.2.2)(typescript@5.9.3) - vite: 6.4.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) '@vitest/pretty-format@2.0.5': dependencies: @@ -21435,7 +21454,7 @@ snapshots: vitest@4.0.18(@types/node@25.2.2)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + '@vitest/mocker': 4.0.18(msw@2.12.9(@types/node@25.2.2)(typescript@5.9.3))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -21452,7 +21471,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.2 From 36eca34d61895eef0b622d0436d2ab87648569c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:02:33 +0000 Subject: [PATCH 3/5] feat(console): integrate auth system with login/register routes, AuthGuard, dynamic user context, and system admin pages Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/package.json | 1 + apps/console/src/App.tsx | 46 ++++++- apps/console/src/components/AppSidebar.tsx | 27 ++-- apps/console/src/pages/ForgotPasswordPage.tsx | 13 ++ apps/console/src/pages/LoginPage.tsx | 20 +++ apps/console/src/pages/RegisterPage.tsx | 19 +++ .../console/src/pages/system/AuditLogPage.tsx | 45 ++++++ .../src/pages/system/OrgManagementPage.tsx | 59 ++++++++ apps/console/src/pages/system/ProfilePage.tsx | 114 +++++++++++++++ .../src/pages/system/RoleManagementPage.tsx | 59 ++++++++ .../src/pages/system/UserManagementPage.tsx | 59 ++++++++ .../console/src/pages/system/systemObjects.ts | 130 ++++++++++++++++++ apps/console/vite.config.ts | 3 + pnpm-lock.yaml | 3 + 14 files changed, 582 insertions(+), 16 deletions(-) create mode 100644 apps/console/src/pages/ForgotPasswordPage.tsx create mode 100644 apps/console/src/pages/LoginPage.tsx create mode 100644 apps/console/src/pages/RegisterPage.tsx create mode 100644 apps/console/src/pages/system/AuditLogPage.tsx create mode 100644 apps/console/src/pages/system/OrgManagementPage.tsx create mode 100644 apps/console/src/pages/system/ProfilePage.tsx create mode 100644 apps/console/src/pages/system/RoleManagementPage.tsx create mode 100644 apps/console/src/pages/system/UserManagementPage.tsx create mode 100644 apps/console/src/pages/system/systemObjects.ts diff --git a/apps/console/package.json b/apps/console/package.json index 85a8ddc4..0036b187 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -25,6 +25,7 @@ "test:ui": "vitest --ui" }, "dependencies": { + "@object-ui/auth": "workspace:*", "@object-ui/components": "workspace:*", "@object-ui/core": "workspace:*", "@object-ui/data-objectstack": "workspace:*", diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index fd7d2ccc..26e384be 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -6,6 +6,7 @@ import { SchemaRendererProvider } from '@object-ui/react'; import { ObjectStackAdapter } from './dataSource'; import type { ConnectionState } from './dataSource'; import appConfig from '../objectstack.shared'; +import { AuthProvider, AuthGuard, useAuth } from '@object-ui/auth'; // Components import { ConsoleLayout } from './components/ConsoleLayout'; @@ -19,6 +20,18 @@ import { PageView } from './components/PageView'; import { ReportView } from './components/ReportView'; import { ExpressionProvider } from './context/ExpressionProvider'; +// Auth Pages +import { LoginPage } from './pages/LoginPage'; +import { RegisterPage } from './pages/RegisterPage'; +import { ForgotPasswordPage } from './pages/ForgotPasswordPage'; + +// System Admin Pages +import { UserManagementPage } from './pages/system/UserManagementPage'; +import { OrgManagementPage } from './pages/system/OrgManagementPage'; +import { RoleManagementPage } from './pages/system/RoleManagementPage'; +import { AuditLogPage } from './pages/system/AuditLogPage'; +import { ProfilePage } from './pages/system/ProfilePage'; + import { useParams } from 'react-router-dom'; import { ThemeProvider } from './components/theme-provider'; @@ -132,7 +145,10 @@ export function AppContent() { ); // Expression context for dynamic visibility/disabled/hidden expressions - const expressionUser = { name: 'John Doe', email: 'admin@example.com', role: 'admin' }; + const { user } = useAuth(); + const expressionUser = user + ? { name: user.name, email: user.email, role: user.role ?? 'user' } + : { name: 'Anonymous', email: '', role: 'guest' }; return ( @@ -191,6 +207,13 @@ export function AppContent() { } /> + + {/* System Administration Routes */} + } /> + } /> + } /> + } /> + } /> @@ -268,12 +291,21 @@ function RootRedirect() { export function App() { return ( - - - } /> - } /> - - + + + + } /> + } /> + } /> + } loadingFallback={}> + + + } /> + } /> + + + ); } diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index 00a6058b..62c9c546 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -46,6 +46,7 @@ import { } from 'lucide-react'; import appConfig from '../../objectstack.shared'; import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider'; +import { useAuth } from '@object-ui/auth'; /** * Resolve a Lucide icon component by name string. @@ -75,6 +76,7 @@ function getIcon(name?: string): React.ComponentType { export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: string, onAppChange: (name: string) => void }) { const { isMobile } = useSidebar(); + const { user, signOut } = useAuth(); const apps = appConfig.apps || []; // Filter out inactive apps @@ -165,12 +167,14 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - - JD + + + {user?.name ? user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) : '?'} +
- John Doe - admin@example.com + {user?.name ?? 'User'} + {user?.email ?? ''}
@@ -184,12 +188,14 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
- - JD + + + {user?.name ? user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) : '?'} +
- John Doe - admin@example.com + {user?.name ?? 'User'} + {user?.email ?? ''}
@@ -201,7 +207,10 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri - + signOut()} + > Log out diff --git a/apps/console/src/pages/ForgotPasswordPage.tsx b/apps/console/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 00000000..5dc99661 --- /dev/null +++ b/apps/console/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,13 @@ +/** + * Forgot Password Page for ObjectStack Console + */ + +import { ForgotPasswordForm } from '@object-ui/auth'; + +export function ForgotPasswordPage() { + return ( +
+ +
+ ); +} diff --git a/apps/console/src/pages/LoginPage.tsx b/apps/console/src/pages/LoginPage.tsx new file mode 100644 index 00000000..55fab5ce --- /dev/null +++ b/apps/console/src/pages/LoginPage.tsx @@ -0,0 +1,20 @@ +/** + * Login Page for ObjectStack Console + */ + +import { useNavigate } from 'react-router-dom'; +import { LoginForm } from '@object-ui/auth'; + +export function LoginPage() { + const navigate = useNavigate(); + + return ( +
+ navigate('/')} + registerUrl="/register" + forgotPasswordUrl="/forgot-password" + /> +
+ ); +} diff --git a/apps/console/src/pages/RegisterPage.tsx b/apps/console/src/pages/RegisterPage.tsx new file mode 100644 index 00000000..91165571 --- /dev/null +++ b/apps/console/src/pages/RegisterPage.tsx @@ -0,0 +1,19 @@ +/** + * Register Page for ObjectStack Console + */ + +import { useNavigate } from 'react-router-dom'; +import { RegisterForm } from '@object-ui/auth'; + +export function RegisterPage() { + const navigate = useNavigate(); + + return ( +
+ navigate('/')} + loginUrl="/login" + /> +
+ ); +} diff --git a/apps/console/src/pages/system/AuditLogPage.tsx b/apps/console/src/pages/system/AuditLogPage.tsx new file mode 100644 index 00000000..435692d1 --- /dev/null +++ b/apps/console/src/pages/system/AuditLogPage.tsx @@ -0,0 +1,45 @@ +/** + * Audit Log Page + * + * Read-only grid displaying system audit logs. + * Shows user actions, resources, timestamps, and details. + */ + +import { systemObjects } from './systemObjects'; + +const auditObject = systemObjects.find((o) => o.name === 'sys_audit_log')!; + +export function AuditLogPage() { + return ( +
+
+

Audit Log

+

View system activity and user actions

+
+ +
+ + + + {auditObject.views[0].columns.map((col) => { + const field = auditObject.fields.find((f) => f.name === col); + return ( + + ); + })} + + + + + + + +
+ {field?.label ?? col} +
+ Connect to ObjectStack server to load audit logs. In production, this page uses plugin-grid in read-only mode. +
+
+
+ ); +} diff --git a/apps/console/src/pages/system/OrgManagementPage.tsx b/apps/console/src/pages/system/OrgManagementPage.tsx new file mode 100644 index 00000000..691541ed --- /dev/null +++ b/apps/console/src/pages/system/OrgManagementPage.tsx @@ -0,0 +1,59 @@ +/** + * Organization Management Page + * + * Displays a list of organizations with member management. + * Reuses the plugin-grid for data display. + */ + +import { useAuth } from '@object-ui/auth'; +import { systemObjects } from './systemObjects'; + +const orgObject = systemObjects.find((o) => o.name === 'sys_org')!; + +export function OrgManagementPage() { + const { user: currentUser } = useAuth(); + const isAdmin = currentUser?.role === 'admin'; + + return ( +
+
+
+

Organization Management

+

Manage organizations and their members

+
+ {isAdmin && ( + + )} +
+ +
+ + + + {orgObject.views[0].columns.map((col) => { + const field = orgObject.fields.find((f) => f.name === col); + return ( + + ); + })} + + + + + + + +
+ {field?.label ?? col} +
+ Connect to ObjectStack server to load organizations. In production, this page uses plugin-grid for full CRUD functionality. +
+
+
+ ); +} diff --git a/apps/console/src/pages/system/ProfilePage.tsx b/apps/console/src/pages/system/ProfilePage.tsx new file mode 100644 index 00000000..4db127a3 --- /dev/null +++ b/apps/console/src/pages/system/ProfilePage.tsx @@ -0,0 +1,114 @@ +/** + * User Profile Page + * + * Allows the authenticated user to view and edit their profile, + * change their password, and manage account settings. + */ + +import React, { useState } from 'react'; +import { useAuth } from '@object-ui/auth'; + +export function ProfilePage() { + const { user, updateUser, isLoading } = useAuth(); + const [name, setName] = useState(user?.name ?? ''); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSaved(false); + + try { + await updateUser({ name }); + setSaved(true); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + if (!user) return null; + + return ( +
+
+

Profile

+

Manage your account settings

+
+ +
+ {/* Profile Info */} +
+

Personal Information

+
+ {error && ( +
+ {error} +
+ )} + {saved && ( +
+ Profile updated successfully. +
+ )} + +
+ + setName(e.target.value)} + required + disabled={isLoading} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ +
+ + +

Email cannot be changed.

+
+ +
+ + +
+ + +
+
+ + {/* Password Change */} +
+

Change Password

+

+ To change your password, use the password reset flow from the login page. +

+
+
+
+ ); +} diff --git a/apps/console/src/pages/system/RoleManagementPage.tsx b/apps/console/src/pages/system/RoleManagementPage.tsx new file mode 100644 index 00000000..ac41ed68 --- /dev/null +++ b/apps/console/src/pages/system/RoleManagementPage.tsx @@ -0,0 +1,59 @@ +/** + * Role Management Page + * + * Displays roles with permission assignment matrix. + * Reuses the plugin-grid for data display. + */ + +import { useAuth } from '@object-ui/auth'; +import { systemObjects } from './systemObjects'; + +const roleObject = systemObjects.find((o) => o.name === 'sys_role')!; + +export function RoleManagementPage() { + const { user: currentUser } = useAuth(); + const isAdmin = currentUser?.role === 'admin'; + + return ( +
+
+
+

Role Management

+

Define roles and assign permissions

+
+ {isAdmin && ( + + )} +
+ +
+ + + + {roleObject.views[0].columns.map((col) => { + const field = roleObject.fields.find((f) => f.name === col); + return ( + + ); + })} + + + + + + + +
+ {field?.label ?? col} +
+ Connect to ObjectStack server to load roles. In production, this page uses plugin-grid with a permission assignment matrix. +
+
+
+ ); +} diff --git a/apps/console/src/pages/system/UserManagementPage.tsx b/apps/console/src/pages/system/UserManagementPage.tsx new file mode 100644 index 00000000..92434d51 --- /dev/null +++ b/apps/console/src/pages/system/UserManagementPage.tsx @@ -0,0 +1,59 @@ +/** + * User Management Page + * + * Displays a list of system users with CRUD capabilities. + * Reuses the plugin-grid for data display. + */ + +import { useAuth } from '@object-ui/auth'; +import { systemObjects } from './systemObjects'; + +const userObject = systemObjects.find((o) => o.name === 'sys_user')!; + +export function UserManagementPage() { + const { user: currentUser } = useAuth(); + const isAdmin = currentUser?.role === 'admin'; + + return ( +
+
+
+

User Management

+

Manage system users and their roles

+
+ {isAdmin && ( + + )} +
+ +
+ + + + {userObject.views[0].columns.map((col) => { + const field = userObject.fields.find((f) => f.name === col); + return ( + + ); + })} + + + + + + + +
+ {field?.label ?? col} +
+ Connect to ObjectStack server to load users. In production, this page uses plugin-grid for full CRUD functionality. +
+
+
+ ); +} diff --git a/apps/console/src/pages/system/systemObjects.ts b/apps/console/src/pages/system/systemObjects.ts new file mode 100644 index 00000000..15ac8794 --- /dev/null +++ b/apps/console/src/pages/system/systemObjects.ts @@ -0,0 +1,130 @@ +/** + * System object definitions for Console administration. + * + * These schemas define the system objects (sys_user, sys_org, sys_role, + * sys_permission, sys_audit_log) used for user management, organization + * management, role-based access control, and audit logging. + */ + +export const systemObjects = [ + { + name: 'sys_user', + label: 'Users', + icon: 'Users', + fields: [ + { name: 'id', type: 'text', label: 'ID', readonly: true }, + { name: 'name', type: 'text', label: 'Name', required: true }, + { name: 'email', type: 'email', label: 'Email', required: true }, + { name: 'role', type: 'select', label: 'Role', options: ['admin', 'member', 'viewer'] }, + { name: 'status', type: 'select', label: 'Status', options: ['active', 'inactive', 'suspended'] }, + { name: 'emailVerified', type: 'boolean', label: 'Email Verified' }, + { name: 'image', type: 'url', label: 'Avatar URL' }, + { name: 'lastLoginAt', type: 'datetime', label: 'Last Login', readonly: true }, + { name: 'createdAt', type: 'datetime', label: 'Created At', readonly: true }, + { name: 'updatedAt', type: 'datetime', label: 'Updated At', readonly: true }, + ], + titleFormat: '{name}', + views: [ + { + name: 'all', + label: 'All Users', + type: 'grid', + columns: ['name', 'email', 'role', 'status', 'lastLoginAt'], + }, + ], + }, + { + name: 'sys_org', + label: 'Organizations', + icon: 'Building2', + fields: [ + { name: 'id', type: 'text', label: 'ID', readonly: true }, + { name: 'name', type: 'text', label: 'Organization Name', required: true }, + { name: 'slug', type: 'text', label: 'Slug', required: true }, + { name: 'description', type: 'textarea', label: 'Description' }, + { name: 'plan', type: 'select', label: 'Plan', options: ['free', 'pro', 'enterprise'] }, + { name: 'status', type: 'select', label: 'Status', options: ['active', 'inactive', 'suspended'] }, + { name: 'memberCount', type: 'number', label: 'Members', readonly: true }, + { name: 'createdAt', type: 'datetime', label: 'Created At', readonly: true }, + ], + titleFormat: '{name}', + views: [ + { + name: 'all', + label: 'All Organizations', + type: 'grid', + columns: ['name', 'slug', 'plan', 'status', 'memberCount'], + }, + ], + }, + { + name: 'sys_role', + label: 'Roles', + icon: 'Shield', + fields: [ + { name: 'id', type: 'text', label: 'ID', readonly: true }, + { name: 'name', type: 'text', label: 'Role Name', required: true }, + { name: 'description', type: 'textarea', label: 'Description' }, + { name: 'permissions', type: 'text', label: 'Permissions' }, + { name: 'isSystem', type: 'boolean', label: 'System Role', readonly: true }, + { name: 'userCount', type: 'number', label: 'Users', readonly: true }, + { name: 'createdAt', type: 'datetime', label: 'Created At', readonly: true }, + ], + titleFormat: '{name}', + views: [ + { + name: 'all', + label: 'All Roles', + type: 'grid', + columns: ['name', 'description', 'isSystem', 'userCount'], + }, + ], + }, + { + name: 'sys_permission', + label: 'Permissions', + icon: 'Key', + fields: [ + { name: 'id', type: 'text', label: 'ID', readonly: true }, + { name: 'name', type: 'text', label: 'Permission Name', required: true }, + { name: 'description', type: 'textarea', label: 'Description' }, + { name: 'resource', type: 'text', label: 'Resource' }, + { name: 'action', type: 'select', label: 'Action', options: ['create', 'read', 'update', 'delete', 'manage'] }, + { name: 'createdAt', type: 'datetime', label: 'Created At', readonly: true }, + ], + titleFormat: '{name}', + views: [ + { + name: 'all', + label: 'All Permissions', + type: 'grid', + columns: ['name', 'resource', 'action', 'description'], + }, + ], + }, + { + name: 'sys_audit_log', + label: 'Audit Log', + icon: 'ScrollText', + fields: [ + { name: 'id', type: 'text', label: 'ID', readonly: true }, + { name: 'action', type: 'text', label: 'Action', readonly: true }, + { name: 'resource', type: 'text', label: 'Resource', readonly: true }, + { name: 'resourceId', type: 'text', label: 'Resource ID', readonly: true }, + { name: 'userId', type: 'text', label: 'User ID', readonly: true }, + { name: 'userName', type: 'text', label: 'User Name', readonly: true }, + { name: 'ipAddress', type: 'text', label: 'IP Address', readonly: true }, + { name: 'details', type: 'textarea', label: 'Details', readonly: true }, + { name: 'createdAt', type: 'datetime', label: 'Timestamp', readonly: true }, + ], + titleFormat: '{action} on {resource}', + views: [ + { + name: 'all', + label: 'All Logs', + type: 'grid', + columns: ['action', 'resource', 'userName', 'ipAddress', 'createdAt'], + }, + ], + }, +]; diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 8b645304..194076ab 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -26,6 +26,9 @@ export default defineConfig({ '@object-ui/react': path.resolve(__dirname, '../../packages/react/src'), '@object-ui/types': path.resolve(__dirname, '../../packages/types/src'), '@object-ui/data-objectstack': path.resolve(__dirname, '../../packages/data-objectstack/src'), + '@object-ui/auth': path.resolve(__dirname, '../../packages/auth/src'), + '@object-ui/permissions': path.resolve(__dirname, '../../packages/permissions/src'), + '@object-ui/tenant': path.resolve(__dirname, '../../packages/tenant/src'), // Missing Plugin Aliases '@object-ui/plugin-aggrid': path.resolve(__dirname, '../../packages/plugin-aggrid/src'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ce6cc71..2e6cd119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: apps/console: dependencies: + '@object-ui/auth': + specifier: workspace:* + version: link:../../packages/auth '@object-ui/components': specifier: workspace:* version: link:../../packages/components From 87a388f9217832c929b29a9550990ef2c4d93ad4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:04:33 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(auth):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20extract=20getUserInitials=20utility,=20improve=20er?= =?UTF-8?q?ror=20handling=20in=20createAuthClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/components/AppSidebar.tsx | 6 +++--- packages/auth/src/UserMenu.tsx | 10 ++-------- packages/auth/src/createAuthClient.ts | 7 +++++-- packages/auth/src/index.ts | 1 + packages/auth/src/types.ts | 17 +++++++++++++++++ 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index 62c9c546..49c59581 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -46,7 +46,7 @@ import { } from 'lucide-react'; import appConfig from '../../objectstack.shared'; import { useExpressionContext, evaluateVisibility } from '../context/ExpressionProvider'; -import { useAuth } from '@object-ui/auth'; +import { useAuth, getUserInitials } from '@object-ui/auth'; /** * Resolve a Lucide icon component by name string. @@ -169,7 +169,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri - {user?.name ? user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) : '?'} + {getUserInitials(user)}
@@ -190,7 +190,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri - {user?.name ? user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) : '?'} + {getUserInitials(user)}
diff --git a/packages/auth/src/UserMenu.tsx b/packages/auth/src/UserMenu.tsx index 87b3155b..a054ad8c 100644 --- a/packages/auth/src/UserMenu.tsx +++ b/packages/auth/src/UserMenu.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { useAuth } from './useAuth'; +import { getUserInitials } from './types'; export interface UserMenuProps { /** Custom avatar URL override */ @@ -43,14 +44,7 @@ export function UserMenu({ return null; } - const initials = user.name - ? user.name - .split(' ') - .map((n) => n[0]) - .join('') - .toUpperCase() - .slice(0, 2) - : user.email?.[0]?.toUpperCase() ?? '?'; + const initials = getUserInitials(user); const imageUrl = avatarUrl ?? user.image; diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index 1e994553..e0ca6304 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -36,8 +36,11 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { }); if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body.message || `Auth request failed: ${response.status}`); + const body = await response.json().catch(() => null); + const message = (body && typeof body === 'object' && 'message' in body) + ? String(body.message) + : `Auth request failed with status ${response.status}`; + throw new Error(message); } return response.json(); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index dd165e87..68537289 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -30,6 +30,7 @@ export { ForgotPasswordForm, type ForgotPasswordFormProps } from './ForgotPasswo export { UserMenu, type UserMenuProps } from './UserMenu'; export { createAuthClient } from './createAuthClient'; export { createAuthenticatedFetch, type AuthenticatedAdapterOptions } from './createAuthenticatedFetch'; +export { getUserInitials } from './types'; // Re-export types for convenience export type { diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index fe5aee6b..86b44ab8 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -30,6 +30,23 @@ export interface AuthUser { [key: string]: unknown; } +/** + * Get user initials from their name or email. + * Returns up to 2 uppercase characters. + */ +export function getUserInitials(user: Pick | null | undefined): string { + if (!user) return '?'; + if (user.name) { + return user.name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); + } + return user.email?.[0]?.toUpperCase() ?? '?'; +} + /** Session information */ export interface AuthSession { /** Access token */ From 148234f688fa0e9aa839e0a7cd9d972a64fe8509 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:06:47 +0000 Subject: [PATCH 5/5] fix(console): move useAuth hook call before early returns in AppContent to comply with rules of hooks Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 26e384be..cffb7e8d 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -38,6 +38,7 @@ import { ThemeProvider } from './components/theme-provider'; export function AppContent() { const [dataSource, setDataSource] = useState(null); const [connectionState, setConnectionState] = useState('disconnected'); + const { user } = useAuth(); // App Selection const navigate = useNavigate(); @@ -145,7 +146,6 @@ export function AppContent() { ); // Expression context for dynamic visibility/disabled/hidden expressions - const { user } = useAuth(); const expressionUser = user ? { name: user.name, email: user.email, role: user.role ?? 'user' } : { name: 'Anonymous', email: '', role: 'guest' };