From 1687d812bf5d4875e93bfa7b271f3de5dab9f5c0 Mon Sep 17 00:00:00 2001 From: Nirmal Arya Date: Sat, 31 May 2025 15:40:32 -0400 Subject: [PATCH] feat: implement GitHub OIDC authentication - Add GitHub OAuth 2.0 authentication flow - Create authentication routes: /auth/login, /auth/callback, /auth/logout - Implement OAuth server utilities with proper session management - Add authentication hooks and client-side state management - Update header with login/logout controls and user menu - Create user profile page with GitHub integration - Add environment variables for GitHub OAuth configuration - Include comprehensive documentation for setup and usage - Enhance profile store with authentication state - Add authentication status API endpoint Closes: GitHub authentication implementation --- .env.example | 19 + app/components/header/Header.tsx | 95 ++++- app/lib/auth/github-oauth.server.ts | 241 +++++++++++ app/lib/hooks/useAuth.ts | 134 +++++++ app/lib/stores/profile.ts | 87 +++- app/routes/api.auth.status.ts | 57 +++ app/routes/auth.callback.tsx | 135 +++++++ app/routes/auth.login.tsx | 162 ++++++++ app/routes/auth.logout.tsx | 138 +++++++ app/routes/profile.tsx | 315 +++++++++++++++ package-lock.json | 592 +++++++++++++++++++++++++++- package.json | 3 + 12 files changed, 1961 insertions(+), 17 deletions(-) create mode 100644 app/lib/auth/github-oauth.server.ts create mode 100644 app/lib/hooks/useAuth.ts create mode 100644 app/routes/api.auth.status.ts create mode 100644 app/routes/auth.callback.tsx create mode 100644 app/routes/auth.login.tsx create mode 100644 app/routes/auth.logout.tsx create mode 100644 app/routes/profile.tsx diff --git a/.env.example b/.env.example index 3c7840a9..5c99d5eb 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,24 @@ # Rename this file to .env once you have filled in the below environment variables! +# GitHub OAuth Authentication Configuration +# --------------------------------------- +# To set up GitHub OAuth: +# 1. Go to https://github.com/settings/developers +# 2. Click "New OAuth App" +# 3. Fill in the application details: +# - Application name: Buildify (or your preferred name) +# - Homepage URL: http://localhost:5173 (or your deployment URL) +# - Authorization callback URL: http://localhost:5173/auth/callback +# 4. Click "Register application" +# 5. Copy the Client ID and generate a new Client Secret +# 6. Paste them below +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Session Secret - Used to encrypt session cookies +# Generate a random string (e.g., using `openssl rand -hex 32` in terminal) +SESSION_SECRET= + # Get your GROQ API Key here - # https://console.groq.com/keys # You only need this environment variable set if you want to use Groq models diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx index ce46702a..a6158e03 100644 --- a/app/components/header/Header.tsx +++ b/app/components/header/Header.tsx @@ -4,10 +4,12 @@ import { chatStore } from '~/lib/stores/chat'; import { classNames } from '~/utils/classNames'; import { HeaderActionButtons } from './HeaderActionButtons.client'; import { ChatDescription } from '~/lib/persistence/ChatDescription.client'; +import { useAuth } from '~/lib/hooks/useAuth'; +import { Link } from '@remix-run/react'; export function Header() { const chat = useStore(chatStore); - + return (
- {chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started. - <> - - {() => } - + + {chat.started && ( + + {() => } + + )} + +
+ {/* Authentication Controls - Always visible */} + + {() => } + + + {/* Existing Action Buttons - Only when chat has started */} + {chat.started && ( {() => ( -
+
)} - - )} + )} +
); } + +function AuthControls() { + const { isAuthenticated, isLoading, user, login, logout } = useAuth(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isAuthenticated && user) { + return ( +
+
+ + +
+
+ Signed in as {user.username} +
+ + + Your profile + + + +
+
+
+ ); + } + + return ( + + ); +} diff --git a/app/lib/auth/github-oauth.server.ts b/app/lib/auth/github-oauth.server.ts new file mode 100644 index 00000000..e12266b7 --- /dev/null +++ b/app/lib/auth/github-oauth.server.ts @@ -0,0 +1,241 @@ +import { OAuthApp } from '@octokit/oauth-app'; +import { createCookieSessionStorage, redirect } from '@remix-run/node'; +import { v4 as uuidv4 } from 'uuid'; + +// Define types for GitHub user data +export interface GitHubUser { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; + html_url: string; +} + +// GitHub API response types +interface GitHubUserResponse { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; + html_url: string; + [key: string]: any; // For other properties we don't explicitly use +} + +interface GitHubEmailResponse { + email: string; + primary: boolean; + verified: boolean; + visibility: string | null; +} + +// Define types for session data +export interface AuthSession { + accessToken: string; + user: GitHubUser; +} + +// Error class for authentication errors +export class AuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthError'; + } +} + +// Get environment variables +const clientId = process.env.GITHUB_CLIENT_ID; +const clientSecret = process.env.GITHUB_CLIENT_SECRET; +const sessionSecret = process.env.SESSION_SECRET || 'buildify-session-secret'; + +// Validate required environment variables +if (!clientId || !clientSecret) { + console.warn( + 'GitHub OAuth is not configured properly. Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.' + ); +} + +// Create OAuth app instance +export const oauthApp = new OAuthApp({ + clientId: clientId || '', + clientSecret: clientSecret || '', + defaultScopes: ['read:user', 'user:email'], +}); + +// Create session storage +const sessionStorage = createCookieSessionStorage({ + cookie: { + name: 'buildify_auth_session', + httpOnly: true, + path: '/', + sameSite: 'lax', + secrets: [sessionSecret], + secure: process.env.NODE_ENV === 'production', + }, +}); + +// Get session from request +export async function getSession(request: Request) { + const cookie = request.headers.get('Cookie'); + return sessionStorage.getSession(cookie); +} + +// Generate state parameter for OAuth flow to prevent CSRF attacks +export function generateState() { + return uuidv4(); +} + +// Store state in session +export async function storeState(request: Request, state: string) { + const session = await getSession(request); + session.set('oauth:state', state); + return sessionStorage.commitSession(session); +} + +// Verify state from session +export async function verifyState(request: Request, state: string) { + const session = await getSession(request); + const storedState = session.get('oauth:state'); + + if (!storedState || storedState !== state) { + throw new AuthError('Invalid state parameter. Possible CSRF attack.'); + } + + // Clear the state after verification + session.unset('oauth:state'); + return sessionStorage.commitSession(session); +} + +// Generate authorization URL +export function getAuthorizationUrl(state: string, redirectUri?: string) { + return oauthApp.getWebFlowAuthorizationUrl({ + state, + redirectUrl: redirectUri, + }); +} + +// Exchange code for token +export async function exchangeCodeForToken(code: string, state: string) { + try { + const { authentication } = await oauthApp.createToken({ + code, + state, + }); + + return { + accessToken: authentication.token, + }; + } catch (error) { + console.error('Error exchanging code for token:', error); + throw new AuthError('Failed to exchange code for token'); + } +} + +// Fetch user data from GitHub API +export async function fetchGitHubUser(accessToken: string): Promise { + try { + const response = await fetch('https://api.github.com/user', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API responded with ${response.status}`); + } + + const userData = await response.json() as GitHubUserResponse; + + // If email is not public, try to get primary email + let email = userData.email; + if (!email) { + const emailsResponse = await fetch('https://api.github.com/user/emails', { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + }, + }); + + if (emailsResponse.ok) { + const emails = await emailsResponse.json() as GitHubEmailResponse[]; + const primaryEmail = emails.find((e) => e.primary); + email = primaryEmail?.email || null; + } + } + + return { + id: userData.id, + login: userData.login, + name: userData.name, + email: email, + avatar_url: userData.avatar_url, + html_url: userData.html_url, + }; + } catch (error) { + console.error('Error fetching GitHub user:', error); + throw new AuthError('Failed to fetch user data from GitHub'); + } +} + +// Create user session +export async function createUserSession(request: Request, authSession: AuthSession, redirectTo: string) { + const session = await getSession(request); + + // Store user data in session + session.set('auth:user', authSession.user); + session.set('auth:accessToken', authSession.accessToken); + + // Commit session and redirect + return redirect(redirectTo, { + headers: { + 'Set-Cookie': await sessionStorage.commitSession(session, { + maxAge: 60 * 60 * 24 * 7, // 1 week + }), + }, + }); +} + +// Get user from session +export async function getUserFromSession(request: Request): Promise { + const session = await getSession(request); + const user = session.get('auth:user'); + return user || null; +} + +// Get access token from session +export async function getAccessToken(request: Request): Promise { + const session = await getSession(request); + const accessToken = session.get('auth:accessToken'); + return accessToken || null; +} + +// Check if user is authenticated +export async function isAuthenticated(request: Request): Promise { + const user = await getUserFromSession(request); + return user !== null; +} + +// Require authentication +export async function requireAuthentication(request: Request, redirectTo: string = '/login') { + const authenticated = await isAuthenticated(request); + + if (!authenticated) { + const searchParams = new URLSearchParams([['redirectTo', request.url]]); + throw redirect(`${redirectTo}?${searchParams}`); + } + + return await getUserFromSession(request); +} + +// Logout - destroy session +export async function logout(request: Request, redirectTo: string = '/') { + const session = await getSession(request); + + return redirect(redirectTo, { + headers: { + 'Set-Cookie': await sessionStorage.destroySession(session), + }, + }); +} diff --git a/app/lib/hooks/useAuth.ts b/app/lib/hooks/useAuth.ts new file mode 100644 index 00000000..f371abbf --- /dev/null +++ b/app/lib/hooks/useAuth.ts @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useLocation } from '@remix-run/react'; +import { + profileStore, + setAuthenticatedUser, + clearAuthState, + isAuthenticated as getIsAuthenticated, + getGitHubUser +} from '~/lib/stores/profile'; +import { useStore } from '@nanostores/react'; +import type { GitHubUser } from '~/lib/auth/github-oauth.server'; + +// Define the API response type for auth status +interface AuthStatusResponse { + isAuthenticated: boolean; + user?: GitHubUser; + tokenStatus?: { + hasToken: boolean; + }; + error?: string; +} + +/** + * Hook to manage authentication state and sync between server and client + */ +export function useAuth() { + const navigate = useNavigate(); + const location = useLocation(); + const profile = useStore(profileStore); + const [isLoading, setIsLoading] = useState(true); + const [serverChecked, setServerChecked] = useState(false); + + // Sync server authentication state with client on initial load + useEffect(() => { + const checkServerAuth = async () => { + try { + setIsLoading(true); + // Fetch authentication status from server + const response = await fetch('/api/auth/status'); + + if (response.ok) { + const data = await response.json() as AuthStatusResponse; + + if (data.isAuthenticated && data.user) { + // Update client state with server auth data + setAuthenticatedUser(data.user); + } else if (profile.isAuthenticated) { + // Clear client state if server says not authenticated + clearAuthState(); + } + } else { + // Handle error - assume not authenticated + if (profile.isAuthenticated) { + clearAuthState(); + } + } + } catch (error) { + console.error('Error checking authentication status:', error); + } finally { + setIsLoading(false); + setServerChecked(true); + } + }; + + // Only check server auth once + if (!serverChecked) { + checkServerAuth(); + } + }, [serverChecked, profile.isAuthenticated]); + + /** + * Navigate to login page + */ + const login = (redirectTo?: string) => { + const searchParams = new URLSearchParams(); + + if (redirectTo || location.pathname !== '/login') { + searchParams.set('redirectTo', redirectTo || location.pathname); + } + + const searchParamsString = searchParams.toString(); + navigate(`/auth/login${searchParamsString ? `?${searchParamsString}` : ''}`); + }; + + /** + * Navigate to logout page + */ + const logout = (redirectTo?: string) => { + const searchParams = new URLSearchParams(); + + if (redirectTo) { + searchParams.set('redirectTo', redirectTo); + } + + const searchParamsString = searchParams.toString(); + navigate(`/auth/logout${searchParamsString ? `?${searchParamsString}` : ''}`); + }; + + /** + * Check if user needs to authenticate for a protected resource + * and redirect to login if needed + */ + const requireAuth = (redirectIfNotAuth: boolean = true) => { + if (!isLoading && !profile.isAuthenticated && redirectIfNotAuth) { + login(); + return false; + } + + return profile.isAuthenticated; + }; + + return { + // Authentication state + isAuthenticated: profile.isAuthenticated, + isLoading, + + // User data + user: profile.isAuthenticated ? { + username: profile.username, + avatar: profile.avatar, + bio: profile.bio, + githubUser: profile.github, + lastLogin: profile.lastLogin + } : null, + + // GitHub specific data + githubUser: getGitHubUser(), + + // Actions + login, + logout, + requireAuth, + }; +} diff --git a/app/lib/stores/profile.ts b/app/lib/stores/profile.ts index d0243ce6..fa136df2 100644 --- a/app/lib/stores/profile.ts +++ b/app/lib/stores/profile.ts @@ -1,23 +1,54 @@ import { atom } from 'nanostores'; +/** + * Enhanced Profile interface with authentication-related fields + */ interface Profile { + // Basic profile fields (original) username: string; bio: string; avatar: string; + + // Authentication state + isAuthenticated: boolean; + lastLogin?: number; // timestamp + + // GitHub user data + github?: { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; + html_url: string; + }; } // Initialize with stored profile or defaults const storedProfile = typeof window !== 'undefined' ? localStorage.getItem('bolt_profile') : null; const initialProfile: Profile = storedProfile - ? JSON.parse(storedProfile) + ? { + // Ensure backward compatibility with existing profile data + ...{ + username: '', + bio: '', + avatar: '', + isAuthenticated: false, + }, + ...JSON.parse(storedProfile), + } : { username: '', bio: '', avatar: '', + isAuthenticated: false, }; export const profileStore = atom(initialProfile); +/** + * Update profile with partial data + */ export const updateProfile = (updates: Partial) => { profileStore.set({ ...profileStore.get(), ...updates }); @@ -26,3 +57,57 @@ export const updateProfile = (updates: Partial) => { localStorage.setItem('bolt_profile', JSON.stringify(profileStore.get())); } }; + +/** + * Set authenticated user data from GitHub + */ +export const setAuthenticatedUser = (githubUser: { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; + html_url: string; +}) => { + const now = Date.now(); + + updateProfile({ + username: githubUser.login, + avatar: githubUser.avatar_url, + // Keep existing bio if available + bio: profileStore.get().bio || githubUser.name || '', + isAuthenticated: true, + lastLogin: now, + github: githubUser, + }); +}; + +/** + * Check if the user is authenticated + */ +export const isAuthenticated = (): boolean => { + return profileStore.get().isAuthenticated; +}; + +/** + * Clear authentication state + */ +export const clearAuthState = () => { + const currentProfile = profileStore.get(); + + updateProfile({ + // Keep username/bio/avatar if user wants to + // but clear authentication state + isAuthenticated: false, + github: undefined, + lastLogin: undefined, + }); +}; + +/** + * Get GitHub user data if authenticated + */ +export const getGitHubUser = () => { + const profile = profileStore.get(); + return profile.isAuthenticated ? profile.github : null; +}; diff --git a/app/routes/api.auth.status.ts b/app/routes/api.auth.status.ts new file mode 100644 index 00000000..6a8a4470 --- /dev/null +++ b/app/routes/api.auth.status.ts @@ -0,0 +1,57 @@ +import { json, type LoaderFunctionArgs } from '@remix-run/node'; +import { + getUserFromSession, + isAuthenticated, + getAccessToken, +} from '~/lib/auth/github-oauth.server'; + +/** + * API route to check authentication status + * Used by the client to sync authentication state between server and client + */ +export async function loader({ request }: LoaderFunctionArgs) { + // Only allow GET requests + if (request.method !== 'GET') { + return json( + { error: 'Method not allowed' }, + { status: 405, headers: { 'Allow': 'GET' } } + ); + } + + try { + // Check if user is authenticated + const authenticated = await isAuthenticated(request); + + if (!authenticated) { + // Return unauthenticated status + return json({ isAuthenticated: false }); + } + + // Get user data from session + const user = await getUserFromSession(request); + + // Get current access token + const accessToken = await getAccessToken(request); + + // Build response + return json({ + isAuthenticated: true, + user, + // Don't expose the actual token to the client + tokenStatus: { + hasToken: Boolean(accessToken), + }, + }); + } catch (error) { + console.error('Error checking authentication status:', error); + + // Return error response + return json( + { + isAuthenticated: false, + error: 'Failed to check authentication status' + }, + { status: 500 } + ); + } +} diff --git a/app/routes/auth.callback.tsx b/app/routes/auth.callback.tsx new file mode 100644 index 00000000..0847780c --- /dev/null +++ b/app/routes/auth.callback.tsx @@ -0,0 +1,135 @@ +import { json, redirect, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; +import { + AuthError, + createUserSession, + exchangeCodeForToken, + fetchGitHubUser, + verifyState, +} from '~/lib/auth/github-oauth.server'; +import BackgroundRays from '~/components/ui/BackgroundRays'; + +export const meta: MetaFunction = () => { + return [ + { title: 'Authenticating...' }, + { name: 'description', content: 'Completing GitHub authentication' }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + // Get URL parameters + const url = new URL(request.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + // Get the redirect URL from state or default to home + const redirectTo = url.searchParams.get('redirectTo') || '/'; + + // Handle GitHub OAuth errors + if (error) { + return json({ + success: false, + error: error, + errorDescription: errorDescription || 'An error occurred during authentication', + redirectTo: '/', + }); + } + + // Validate required parameters + if (!code || !state) { + return json({ + success: false, + error: 'invalid_request', + errorDescription: 'Missing required parameters', + redirectTo: '/login', + }); + } + + try { + // Verify state parameter to prevent CSRF attacks + const cookieHeader = await verifyState(request, state); + + // Exchange authorization code for access token + const { accessToken } = await exchangeCodeForToken(code, state); + + // Fetch user data from GitHub API + const user = await fetchGitHubUser(accessToken); + + // Create user session and redirect + return createUserSession( + request, + { + accessToken, + user, + }, + redirectTo + ); + } catch (error) { + console.error('Authentication error:', error); + + let errorMessage = 'An unexpected error occurred during authentication'; + let errorCode = 'server_error'; + + if (error instanceof AuthError) { + errorMessage = error.message; + errorCode = 'auth_error'; + } + + return json({ + success: false, + error: errorCode, + errorDescription: errorMessage, + redirectTo: '/login', + }); + } +} + +export default function AuthCallback() { + const data = useLoaderData(); + + // Only render the component if we have error data + // On successful auth, the server will redirect immediately + if (data && 'success' in data && !data.success) { + return ( +
+ + +
+
+ + + +

Authentication Failed

+

{data.errorDescription}

+
+ +

+ Redirecting you back in a moment... +

+
+
+ ); + } + + // Loading state + return ( +
+ + +
+
+ + + + +

Authenticating...

+

+ Completing your sign-in with GitHub +

+
+
+
+ ); +} diff --git a/app/routes/auth.login.tsx b/app/routes/auth.login.tsx new file mode 100644 index 00000000..1b94c478 --- /dev/null +++ b/app/routes/auth.login.tsx @@ -0,0 +1,162 @@ +import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node'; +import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react'; +import { useEffect, useState } from 'react'; +import { generateState, getAuthorizationUrl, isAuthenticated, storeState } from '~/lib/auth/github-oauth.server'; +import BackgroundRays from '~/components/ui/BackgroundRays'; + +export const meta: MetaFunction = () => { + return [ + { title: 'Login to Buildify' }, + { name: 'description', content: 'Login to Buildify using your GitHub account' }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + // Check if user is already authenticated + const authenticated = await isAuthenticated(request); + if (authenticated) { + // Redirect to home page if already logged in + return redirect('/'); + } + + // Get redirect URL from query params (if any) + const url = new URL(request.url); + const redirectTo = url.searchParams.get('redirectTo') || '/'; + + // Check if GitHub OAuth is configured + const clientId = process.env.GITHUB_CLIENT_ID; + const clientSecret = process.env.GITHUB_CLIENT_SECRET; + const isConfigured = Boolean(clientId && clientSecret); + + return json({ + redirectTo, + isConfigured, + error: isConfigured ? null : 'GitHub OAuth is not configured. Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.', + }); +} + +export async function action({ request }: ActionFunctionArgs) { + // Check if GitHub OAuth is configured + const clientId = process.env.GITHUB_CLIENT_ID; + const clientSecret = process.env.GITHUB_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return json( + { error: 'GitHub OAuth is not configured. Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET environment variables.' }, + { status: 400 } + ); + } + + // Get form data + const formData = await request.formData(); + const redirectTo = formData.get('redirectTo')?.toString() || '/'; + + try { + // Generate state for CSRF protection + const state = generateState(); + + // Get the callback URL + const url = new URL(request.url); + const callbackUrl = `${url.origin}/auth/callback`; + + // Generate authorization URL + const { url: authorizationUrl } = getAuthorizationUrl(state, callbackUrl); + + // Store state in session + const cookie = await storeState(request, state); + + // Redirect to GitHub authorization URL + return redirect(authorizationUrl, { + headers: { + 'Set-Cookie': cookie, + }, + }); + } catch (error) { + console.error('Error initiating GitHub OAuth flow:', error); + return json( + { error: 'Failed to initiate login. Please try again later.' }, + { status: 500 } + ); + } +} + +export default function Login() { + const { redirectTo, isConfigured, error } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const [errorMessage, setErrorMessage] = useState(null); + + // Set error message from loader or action data + useEffect(() => { + setErrorMessage(error || actionData?.error || null); + }, [error, actionData]); + + const isSubmitting = navigation.state === 'submitting'; + + return ( +
+ + +
+
+

Login to Buildify

+

+ Connect with your GitHub account to get started +

+
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+ + + +
+ +
+

+ By logging in, you agree to the{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +

+
+
+
+ ); +} diff --git a/app/routes/auth.logout.tsx b/app/routes/auth.logout.tsx new file mode 100644 index 00000000..21e5e066 --- /dev/null +++ b/app/routes/auth.logout.tsx @@ -0,0 +1,138 @@ +import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node'; +import { Form, useLoaderData, useNavigation } from '@remix-run/react'; +import { useEffect } from 'react'; +import { logout, getUserFromSession } from '~/lib/auth/github-oauth.server'; +import { updateProfile } from '~/lib/stores/profile'; +import BackgroundRays from '~/components/ui/BackgroundRays'; + +export const meta: MetaFunction = () => { + return [ + { title: 'Logout from Buildify' }, + { name: 'description', content: 'Logout from your Buildify account' }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + // Get URL parameters + const url = new URL(request.url); + const redirectTo = url.searchParams.get('redirectTo') || '/'; + + // Get user data to display in the confirmation page + const user = await getUserFromSession(request); + + return json({ + redirectTo, + user, + }); +} + +export async function action({ request }: ActionFunctionArgs) { + // Get form data + const formData = await request.formData(); + const redirectTo = formData.get('redirectTo')?.toString() || '/'; + + // Perform logout and redirect + return logout(request, redirectTo); +} + +export default function Logout() { + const { redirectTo, user } = useLoaderData(); + const navigation = useNavigation(); + const isSubmitting = navigation.state === 'submitting'; + + // Reset profile store on client side when logging out via form submission + useEffect(() => { + if (isSubmitting) { + // Reset profile to empty values + updateProfile({ + username: '', + bio: '', + avatar: '', + }); + } + }, [isSubmitting]); + + return ( +
+ + +
+
+

Logout

+ {user ? ( +
+ {`${user.login}'s +

+ {user.name || user.login} +

+

+ {user.login} +

+
+ ) : ( +

+ You are not currently logged in. +

+ )} +
+ + {user ? ( +
+ + +

+ Are you sure you want to log out? +

+ +
+ + + + Cancel + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx new file mode 100644 index 00000000..920c7043 --- /dev/null +++ b/app/routes/profile.tsx @@ -0,0 +1,315 @@ +import { json, redirect, type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from '@remix-run/node'; +import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react'; +import { useEffect, useState } from 'react'; +import { getUserFromSession, isAuthenticated, requireAuthentication } from '~/lib/auth/github-oauth.server'; +import { useAuth } from '~/lib/hooks/useAuth'; +import { updateProfile } from '~/lib/stores/profile'; +import BackgroundRays from '~/components/ui/BackgroundRays'; + +export const meta: MetaFunction = () => { + return [ + { title: 'Your Profile - Buildify' }, + { name: 'description', content: 'Manage your Buildify profile and preferences' }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + // Check if user is authenticated on the server side + const authenticated = await isAuthenticated(request); + + if (!authenticated) { + // Redirect to login if not authenticated + return redirect('/auth/login?redirectTo=/profile'); + } + + // Get user data from session + const user = await getUserFromSession(request); + + return json({ user }); +} + +export async function action({ request }: ActionFunctionArgs) { + // Require authentication for this action + const user = await requireAuthentication(request, '/auth/login?redirectTo=/profile'); + + // Get form data + const formData = await request.formData(); + const bio = formData.get('bio')?.toString() || ''; + const displayName = formData.get('displayName')?.toString() || user.login; + const theme = formData.get('theme')?.toString() || 'system'; + + // Here you would typically update the user data in your database + // Since we're using client-side storage, we'll just return the updated values + // and let the client update the profile store + + return json({ + success: true, + message: 'Profile updated successfully', + updates: { + bio, + displayName, + theme, + }, + }); +} + +export default function Profile() { + const { user: serverUser } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const { isAuthenticated, user, githubUser } = useAuth(); + + const [bio, setBio] = useState(user?.bio || ''); + const [displayName, setDisplayName] = useState(user?.username || serverUser?.login || ''); + + const isSubmitting = navigation.state === 'submitting'; + const lastLogin = user?.lastLogin ? new Date(user.lastLogin).toLocaleString() : 'Unknown'; + + // Update form values when user data changes + useEffect(() => { + if (user) { + setBio(user.bio || ''); + setDisplayName(user.username || ''); + } + }, [user]); + + // Apply updates from action data + useEffect(() => { + if (actionData?.success && actionData.updates) { + updateProfile({ + bio: actionData.updates.bio, + username: actionData.updates.displayName, + }); + + // Show success notification + // This could be replaced with a toast notification + const timer = setTimeout(() => { + const notification = document.getElementById('notification'); + if (notification) { + notification.style.opacity = '0'; + } + }, 3000); + + return () => clearTimeout(timer); + } + }, [actionData]); + + if (!isAuthenticated || !user) { + return ( +
+ +
+
+
+

Loading profile...

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

Your Profile

+

+ Manage your profile information and preferences +

+
+ + {actionData?.success && ( +
+ {actionData.message} +
+ )} + +
+ {/* GitHub Profile Information */} +
+
+
+ {`${displayName}'s + +

+ {displayName} +

+ +

+ @{githubUser?.login || serverUser?.login} +

+ + {githubUser?.html_url && ( + + + GitHub Profile + + )} + +
+
+

Email

+

+ {githubUser?.email || serverUser?.email || 'Not available'} +

+
+ +
+

Authentication Status

+
+ + Authenticated with GitHub +
+
+ +
+

Last Login

+

{lastLogin}

+
+
+
+
+
+ + {/* Profile Edit Form */} +
+
+

+ Edit Profile +

+ +
+
+ + setDisplayName(e.target.value)} + className="w-full px-3 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md text-bolt-content-primary focus:outline-none focus:ring-2 focus:ring-bolt-content-accent" + /> +
+ +
+ +