From 12dc03b6364e5bef8d22e7d10311f82e447151c3 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 22 Jan 2026 00:32:48 +0100 Subject: [PATCH] feat: add organization onboarding flow and auth enforcement --- app/actions/auth.ts | 14 +++-- app/onboarding/page.tsx | 107 +++++++++++++++++++++++++++++++++++++++ components/ui/Button.tsx | 37 ++++++++++++++ components/ui/Input.tsx | 37 ++++++++++++++ lib/api/organization.ts | 46 +++++++++++++++++ lib/auth/context.tsx | 52 ++++++++++++++++++- 6 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 app/onboarding/page.tsx create mode 100644 components/ui/Button.tsx create mode 100644 components/ui/Input.tsx create mode 100644 lib/api/organization.ts diff --git a/app/actions/auth.ts b/app/actions/auth.ts index 9dbff34..de5dd60 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -25,6 +25,8 @@ interface UserPayload { sub: string email?: string totp_enabled?: boolean + org_id?: string + role?: string } export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) { @@ -83,7 +85,9 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir user: { id: payload.sub, email: payload.email || 'user@ciphera.net', - totp_enabled: payload.totp_enabled || false + totp_enabled: payload.totp_enabled || false, + org_id: payload.org_id, + role: payload.role } } @@ -124,7 +128,9 @@ export async function setSessionAction(accessToken: string, refreshToken: string user: { id: payload.sub, email: payload.email || 'user@ciphera.net', - totp_enabled: payload.totp_enabled || false + totp_enabled: payload.totp_enabled || false, + org_id: payload.org_id, + role: payload.role } } } catch (e) { @@ -161,7 +167,9 @@ export async function getSessionAction() { return { id: payload.sub, email: payload.email || 'user@ciphera.net', - totp_enabled: payload.totp_enabled || false + totp_enabled: payload.totp_enabled || false, + org_id: payload.org_id, + role: payload.role } } catch { return null diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx new file mode 100644 index 0000000..29fef58 --- /dev/null +++ b/app/onboarding/page.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { createOrganization } from '@/lib/api/organization' +import { useAuth } from '@/lib/auth/context' +import LoadingOverlay from '@/components/LoadingOverlay' +import Button from '@/components/ui/Button' +import Input from '@/components/ui/Input' + +export default function OnboardingPage() { + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const router = useRouter() + const { user } = useAuth() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + + try { + await createOrganization(name, slug) + // * Redirect to home, AuthContext will detect the new org and auto-switch + router.push('/') + } catch (err: any) { + setError(err.message || 'Failed to create organization') + } finally { + setLoading(false) + } + } + + // * Auto-generate slug from name + const handleNameChange = (e: React.ChangeEvent) => { + const val = e.target.value + setName(val) + if (!slug || slug === name.toLowerCase().replace(/[^a-z0-9]/g, '-')) { + setSlug(val.toLowerCase().replace(/[^a-z0-9]/g, '-')) + } + } + + if (loading) return + + return ( +
+
+
+

+ Welcome to Pulse +

+

+ To get started, please create an organization for your team. +

+
+ +
+
+
+ + +
+
+ + setSlug(e.target.value)} + /> +

+ This will be used in your organization's URL. +

+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+
+
+
+ ) +} diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx new file mode 100644 index 0000000..1f591d9 --- /dev/null +++ b/components/ui/Button.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'ghost'; + isLoading?: boolean; +} + +export const Button = React.forwardRef( + ({ className = '', variant = 'primary', isLoading, children, disabled, ...props }, ref) => { + const baseStyles = 'inline-flex items-center justify-center rounded-xl text-sm font-medium px-5 py-2.5 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'; + + const variants = { + primary: 'bg-blue-600 text-white shadow-sm hover:bg-blue-700 focus:ring-blue-500', + secondary: 'bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 text-neutral-900 dark:text-white hover:bg-neutral-50 dark:hover:bg-neutral-800 shadow-sm focus:ring-neutral-200', + ghost: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800 focus:ring-neutral-200', + }; + + return ( + + ); + } +); + +Button.displayName = 'Button'; diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx new file mode 100644 index 0000000..1db4034 --- /dev/null +++ b/components/ui/Input.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +export interface InputProps extends React.InputHTMLAttributes { + error?: string; + icon?: React.ReactNode; +} + +export const Input = React.forwardRef( + ({ className = '', error, icon, ...props }, ref) => { + return ( +
+ + {icon && ( +
+ {icon} +
+ )} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/lib/api/organization.ts b/lib/api/organization.ts new file mode 100644 index 0000000..7d03722 --- /dev/null +++ b/lib/api/organization.ts @@ -0,0 +1,46 @@ +import apiRequest from './client' + +export interface Organization { + id: string + name: string + slug: string + role: 'owner' | 'admin' | 'member' + joined_at: string +} + +export interface OrganizationMember { + organization_id: string + user_id: string + role: string + joined_at: string + organization_name: string + organization_slug: string +} + +// * Fetch user's organizations +export async function getUserOrganizations(): Promise<{ organizations: OrganizationMember[] }> { + // * Route to Auth Service + // * Note: The client.ts prepends /api/v1, but the auth service routes are /api/v1/auth/organizations + // * We need to be careful with the prefix. + // * client.ts: if endpoint starts with /auth, it uses AUTH_API_URL + /api/v1 + endpoint + // * So if we pass /auth/organizations, it becomes AUTH_API_URL/api/v1/auth/organizations + // * This matches the router group in main.go: v1.Group("/auth").Group("/organizations") + return apiRequest<{ organizations: OrganizationMember[] }>('/auth/organizations') +} + +// * Create a new organization +export async function createOrganization(name: string, slug: string): Promise { + return apiRequest('/auth/organizations', { + method: 'POST', + body: JSON.stringify({ name, slug }), + }) +} + +// * Switch context to organization (returns new token) +export async function switchContext(organizationId: string): Promise<{ token: string, refresh_token: string }> { + // * Route in main.go is /api/v1/auth/switch-context + return apiRequest<{ token: string, refresh_token: string }>('/auth/switch-context', { + method: 'POST', + body: JSON.stringify({ organization_id: organizationId }), + }) +} diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index faf1fde..eda4134 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -1,15 +1,18 @@ 'use client' import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' -import { useRouter } from 'next/navigation' +import { useRouter, usePathname } from 'next/navigation' import apiRequest from '@/lib/api/client' import LoadingOverlay from '@/components/LoadingOverlay' -import { logoutAction, getSessionAction } from '@/app/actions/auth' +import { logoutAction, getSessionAction, setSessionAction } from '@/app/actions/auth' +import { getUserOrganizations, switchContext } from '@/lib/api/organization' interface User { id: string email: string totp_enabled: boolean + org_id?: string + role?: string } interface AuthContextType { @@ -35,6 +38,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [loading, setLoading] = useState(true) const [isLoggingOut, setIsLoggingOut] = useState(false) const router = useRouter() + const pathname = usePathname() const login = (userData: User) => { // * We still store user profile in localStorage for optimistic UI, but NOT the token @@ -97,6 +101,50 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { init() }, []) + // * Organization Wall & Auto-Switch + useEffect(() => { + const checkOrg = async () => { + if (!loading && user) { + // * If we are on onboarding, skip check + if (pathname?.startsWith('/onboarding')) return + + try { + const { organizations } = await getUserOrganizations() + + if (organizations.length === 0) { + // * No organizations -> Redirect to Onboarding + router.push('/onboarding') + return + } + + // * If user has organizations but no context (org_id), switch to the first one + if (!user.org_id && organizations.length > 0) { + const firstOrg = organizations[0] + console.log('Auto-switching to organization:', firstOrg.organization_name) + + try { + const { token, refresh_token } = await switchContext(firstOrg.organization_id) + + // * Update session cookie + const result = await setSessionAction(token, refresh_token) + if (result.success && result.user) { + setUser(result.user) + localStorage.setItem('user', JSON.stringify(result.user)) + router.refresh() + } + } catch (e) { + console.error('Failed to auto-switch context', e) + } + } + } catch (e) { + console.error("Failed to fetch organizations", e) + } + } + } + + checkOrg() + }, [loading, user, pathname, router]) + return ( {isLoggingOut && }