diff --git a/ts/mobile-starter/.gitignore b/ts/mobile-starter/.gitignore new file mode 100644 index 00000000..279c4059 --- /dev/null +++ b/ts/mobile-starter/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.encore +encore.gen +.turbo +.expo diff --git a/ts/mobile-starter/README.md b/ts/mobile-starter/README.md new file mode 100644 index 00000000..34ff9c5f --- /dev/null +++ b/ts/mobile-starter/README.md @@ -0,0 +1,154 @@ +# Mobile App Starter with WorkOS Auth + +A full-stack monorepo starter with an **Encore.ts** backend, **Vite/React** web app, and **Expo/React Native** mobile app -- all sharing the same WorkOS-powered authentication system. + +## Features + +- Email/password authentication with email verification +- OAuth login (Google, Microsoft) +- Password reset flow +- Organization management with role-based access control +- Member invitations with role assignment +- Token refresh with automatic scheduling +- Protected routes on both web and mobile + +## Roles & Permissions + +| Permission | Admin | Member | +|---|---|---| +| View dashboard | Yes | Yes | +| View/edit profile | Yes | Yes | +| View members | Yes | Yes | +| Invite members | Yes | No | +| Remove members | Yes | No | + +## Prerequisites + +- **[Encore CLI](https://encore.dev/docs/ts/install)** installed +- **[Bun](https://bun.sh/)** (package manager) +- **[WorkOS](https://workos.com/)** account with: + - Client ID + - API Key + - An organization created + - Email/password authentication enabled + - OAuth providers configured (Google and/or Microsoft) + +## Create app + +Create a new Encore application from this template: + +```bash +encore app create --example=ts/mobile-starter +``` + +## Configure WorkOS Secrets + +Set the required secrets for the auth service: + +```bash +encore secret set WorkOSClientId --type dev,local,pr +# Enter your WorkOS Client ID + +encore secret set WorkOSApiKey --type dev,local,pr +# Enter your WorkOS API Key +``` + +## WorkOS Configuration + +In your [WorkOS Dashboard](https://dashboard.workos.com/): + +1. **Authentication**: Enable "Email + Password" authentication method +2. **OAuth**: Configure Google and/or Microsoft OAuth providers +3. **Redirect URIs**: Add the following: + - Web: `http://localhost:3001/auth/oauth/callback` + - Native: `mobile-starter://auth/callback` +4. **Organizations**: Create at least one organization +5. **Roles**: Create `admin` and `member` roles in your organization settings + +## Run the Backend + +```bash +encore run +``` + +The Encore development dashboard is available at [http://localhost:9400](http://localhost:9400). + +## Run the Web App + +```bash +cd web +bun install +bun run dev +``` + +The web app runs at [http://localhost:3001](http://localhost:3001). + +## Run the Native App + +```bash +cd native +bun install +bun run dev +``` + +This starts the Expo development server. Use the Expo Go app or a simulator to run it. + +## Generate API Clients + +After modifying backend endpoints, regenerate the typed API clients: + +```bash +# Install task runner (if not already installed) +# brew install go-task + +# Generate for both web and native +task gen:api + +# Or individually +task gen:api:web +task gen:api:native +``` + +## Project Structure + +``` +├── backend/ # Encore.ts backend +│ └── auth/ # Auth service (WorkOS) +│ ├── auth.ts # JWT verification + gateway +│ ├── permissions.ts # Roles & permissions +│ ├── sign-in.ts # Email/password login +│ ├── sign-up.ts # Registration +│ ├── oauth.ts # OAuth URL + callback +│ ├── invitations.ts # Invite/list/revoke members +│ └── ... # refresh, verify-email, password-reset, session, sign-out +│ +├── web/ # Vite + React 19 + TanStack Router +│ └── src/ +│ ├── features/auth/ # Auth provider, forms, OAuth buttons +│ ├── features/invitations/ # Invite form + list +│ ├── routes/ # TanStack file-based routes +│ └── lib/ # API client, permissions, utilities +│ +└── native/ # Expo 54 + React Native + ├── app/ # Expo Router (tabs: Dashboard, Members, Profile) + ├── features/auth/ # SecureStore-based auth provider + └── lib/ # API client, permissions, token utils +``` + +## Deployment + +### Self-hosting + +See the [Encore self-hosting docs](https://encore.dev/docs/ts/self-host/build) for how to build and deploy your application. + +### Encore Cloud Platform + +Deploy your application to a free staging environment on Encore's cloud: + +```bash +git add -A . +git commit -m "Initial commit" +git push encore +``` + +Then head over to the [Encore Cloud Dashboard](https://app.encore.dev) to monitor your deployment. diff --git a/ts/mobile-starter/Taskfile.yml b/ts/mobile-starter/Taskfile.yml new file mode 100644 index 00000000..0daf3e1d --- /dev/null +++ b/ts/mobile-starter/Taskfile.yml @@ -0,0 +1,43 @@ +version: "3" + +vars: + ENCORE_APP_ID: "{{ENCORE_APP_ID}}" + +tasks: + dev: + desc: Start Encore backend + cmd: encore run + + web: + desc: Start web dev server + dir: web + cmd: bun run dev + + native: + desc: Start Expo dev server + dir: native + cmd: bun run dev + + install: + desc: Install all dependencies + cmd: bun install + + gen:api:web: + desc: Generate Encore client for web + cmd: encore gen client {{.ENCORE_APP_ID}} --output=web/src/lib/api/client.gen.ts --env=local + + gen:api:native: + desc: Generate Encore client for native + cmd: encore gen client {{.ENCORE_APP_ID}} --output=native/lib/api/client.gen.ts --env=local + + gen:api: + desc: Generate Encore clients for both web and native + deps: [gen:api:web, gen:api:native] + + lint: + desc: Run linting + cmd: bun run lint + + lint:fix: + desc: Fix linting issues + cmd: bun run lint:fix diff --git a/ts/mobile-starter/backend/auth/auth.helpers.ts b/ts/mobile-starter/backend/auth/auth.helpers.ts new file mode 100644 index 00000000..51e3d82e --- /dev/null +++ b/ts/mobile-starter/backend/auth/auth.helpers.ts @@ -0,0 +1,84 @@ +import type { AuthenticationResponse, User } from "@workos-inc/node"; +import { APIError } from "encore.dev/api"; +import { getAuthData } from "~encore/auth"; +import { hasPermission, mapRole, type Permission } from "./permissions"; + +export interface UserInfo { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + profilePictureUrl: string | null; +} + +export function toUserInfo(user: User): UserInfo { + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + profilePictureUrl: user.profilePictureUrl, + }; +} + +export function toAuthResult(response: AuthenticationResponse) { + return { + accessToken: response.accessToken, + refreshToken: response.refreshToken, + user: toUserInfo(response.user), + }; +} + +interface OauthError extends Error { + name: "OauthException"; + error?: string; + rawData?: Record; +} + +export function isOauthException(error: unknown): error is OauthError { + return error instanceof Error && error.name === "OauthException"; +} + +export function getPendingToken(error: OauthError): string | undefined { + return error.rawData?.pending_authentication_token as string | undefined; +} + +export function handleWorkOSError( + error: unknown, + fallbackMessage: string, +): never { + if (error instanceof APIError) { + throw error; + } + + if (error instanceof Error && "status" in error) { + const status = (error as Error & { status?: number }).status; + + if (status === 401) { + throw APIError.unauthenticated("Invalid email or password"); + } + if (status === 409) { + throw APIError.alreadyExists("A user with this email already exists"); + } + if (status === 422) { + throw APIError.invalidArgument(error.message); + } + if (status === 429) { + throw APIError.resourceExhausted("Too many requests"); + } + } + + throw APIError.internal(fallbackMessage); +} + +export function requirePermission(permission: Permission): void { + const authData = getAuthData(); + if (!authData) { + throw APIError.unauthenticated("Not authenticated"); + } + + const role = mapRole(authData.role); + if (!hasPermission(role, permission)) { + throw APIError.permissionDenied(`Missing permission: ${permission}`); + } +} diff --git a/ts/mobile-starter/backend/auth/auth.ts b/ts/mobile-starter/backend/auth/auth.ts new file mode 100644 index 00000000..f310b2ce --- /dev/null +++ b/ts/mobile-starter/backend/auth/auth.ts @@ -0,0 +1,56 @@ +import { WorkOS } from "@workos-inc/node"; +import { APIError, Gateway, type Header } from "encore.dev/api"; +import { authHandler } from "encore.dev/auth"; +import { secret } from "encore.dev/config"; +import { createRemoteJWKSet, jwtVerify } from "jose"; + +const workOSClientId = secret("WorkOSClientId"); +const workOSApiKey = secret("WorkOSApiKey"); + +let _workos: WorkOS | undefined; +export const getWorkOS = () => { + if (!_workos) { + _workos = new WorkOS(workOSApiKey()); + } + return _workos; +}; + +const getJWKS = () => + createRemoteJWKSet( + new URL(`https://api.workos.com/sso/jwks/${workOSClientId()}`), + ); + +interface AuthParams { + authorization: Header<"Authorization">; +} + +export interface AuthData { + userID: string; + email: string; + role: "admin" | "member"; + organizationId: string; +} + +export const auth = authHandler(async (params) => { + const token = params.authorization.replace("Bearer ", ""); + if (!token) { + throw APIError.unauthenticated("missing authorization token"); + } + + try { + const { payload } = await jwtVerify(token, getJWKS()); + + return { + userID: payload.sub ?? "", + email: (payload.email as string) ?? "", + role: ((payload.role as string) ?? "member") as "admin" | "member", + organizationId: (payload.org_id as string) ?? "", + }; + } catch { + throw APIError.unauthenticated("could not verify token"); + } +}); + +export const gateway = new Gateway({ + authHandler: auth, +}); diff --git a/ts/mobile-starter/backend/auth/encore.service.ts b/ts/mobile-starter/backend/auth/encore.service.ts new file mode 100644 index 00000000..4fc6e66b --- /dev/null +++ b/ts/mobile-starter/backend/auth/encore.service.ts @@ -0,0 +1,3 @@ +import { Service } from "encore.dev/service"; + +export default new Service("auth"); diff --git a/ts/mobile-starter/backend/auth/invitations.ts b/ts/mobile-starter/backend/auth/invitations.ts new file mode 100644 index 00000000..0d693349 --- /dev/null +++ b/ts/mobile-starter/backend/auth/invitations.ts @@ -0,0 +1,119 @@ +import { APIError, api } from "encore.dev/api"; +import { getAuthData } from "~encore/auth"; +import { getWorkOS } from "./auth"; +import { requirePermission } from "./auth.helpers"; +import { P } from "./permissions"; + +interface SendInvitationRequest { + email: string; + role: string; +} + +interface SendInvitationResponse { + invitationId: string; + email: string; + status: string; +} + +export const sendInvitation = api( + { + expose: true, + auth: true, + method: "POST", + path: "/api/auth/invitations", + }, + async (req: SendInvitationRequest): Promise => { + requirePermission(P.MEMBERS_INVITE); + const authData = getAuthData(); + if (!authData?.organizationId) { + throw APIError.permissionDenied("no organization context"); + } + + const workos = getWorkOS(); + const invitation = await workos.userManagement.sendInvitation({ + email: req.email, + organizationId: authData.organizationId, + roleSlug: req.role, + inviterUserId: authData.userID, + }); + + return { + invitationId: invitation.id, + email: invitation.email, + status: invitation.state, + }; + }, +); + +interface InvitationItem { + id: string; + email: string; + status: string; + createdAt: string; + expiresAt: string; +} + +interface ListInvitationsResponse { + invitations: InvitationItem[]; +} + +export const listInvitations = api( + { + expose: true, + auth: true, + method: "GET", + path: "/api/auth/invitations", + }, + async (): Promise => { + requirePermission(P.MEMBERS_VIEW); + const authData = getAuthData(); + if (!authData?.organizationId) { + throw APIError.permissionDenied("no organization context"); + } + + const workos = getWorkOS(); + const result = await workos.userManagement.listInvitations({ + organizationId: authData.organizationId, + }); + + return { + invitations: result.data.map((inv) => ({ + id: inv.id, + email: inv.email, + status: inv.state, + createdAt: inv.createdAt, + expiresAt: inv.expiresAt, + })), + }; + }, +); + +interface RevokeInvitationRequest { + id: string; +} + +export const revokeInvitation = api( + { + expose: true, + auth: true, + method: "DELETE", + path: "/api/auth/invitations/:id", + }, + async (req: RevokeInvitationRequest): Promise => { + requirePermission(P.MEMBERS_REMOVE); + const authData = getAuthData(); + if (!authData?.organizationId) { + throw APIError.permissionDenied("no organization context"); + } + + const workos = getWorkOS(); + const invitation = await workos.userManagement.getInvitation(req.id); + if (invitation.organizationId !== authData.organizationId) { + throw APIError.permissionDenied( + "invitation belongs to another organization", + ); + } + + await workos.userManagement.revokeInvitation(req.id); + }, +); diff --git a/ts/mobile-starter/backend/auth/oauth.ts b/ts/mobile-starter/backend/auth/oauth.ts new file mode 100644 index 00000000..ab45f949 --- /dev/null +++ b/ts/mobile-starter/backend/auth/oauth.ts @@ -0,0 +1,91 @@ +import { api } from "encore.dev/api"; +import { secret } from "encore.dev/config"; +import { getWorkOS } from "./auth"; +import { handleWorkOSError, toAuthResult, type UserInfo } from "./auth.helpers"; +import { + getOrgName, + isOrgSelectionRequired, + resolveOrgAuthByUserId, +} from "./org-auth"; + +const workOSClientId = secret("WorkOSClientId"); + +type OAuthProvider = "GoogleOAuth" | "MicrosoftOAuth"; + +interface OAuthUrlRequest { + provider: OAuthProvider; + redirectUri: string; +} + +interface OAuthUrlResponse { + url: string; +} + +export const getOAuthUrl = api( + { + expose: true, + auth: false, + method: "GET", + path: "/api/auth/oauth/url", + }, + async (req: OAuthUrlRequest): Promise => { + try { + const url = await getWorkOS().userManagement.getAuthorizationUrl({ + provider: req.provider, + clientId: workOSClientId(), + redirectUri: req.redirectUri, + }); + + return { url }; + } catch (error: unknown) { + handleWorkOSError(error, "Failed to get OAuth URL"); + } + }, +); + +interface OAuthCallbackRequest { + code: string; +} + +interface OAuthCallbackResponse { + accessToken: string; + refreshToken: string; + user: UserInfo; + organizationName?: string; +} + +export const oauthCallback = api( + { + expose: true, + auth: false, + method: "POST", + path: "/api/auth/oauth/callback", + }, + async (req: OAuthCallbackRequest): Promise => { + try { + const response = await getWorkOS().userManagement.authenticateWithCode({ + clientId: workOSClientId(), + code: req.code, + }); + + const organizationName = await getOrgName(response); + return { ...toAuthResult(response), organizationName }; + } catch (error: unknown) { + const orgSelection = isOrgSelectionRequired(error); + if (orgSelection?.userId) { + const orgResult = await resolveOrgAuthByUserId( + orgSelection.userId, + orgSelection.pendingToken, + ); + if (orgResult) { + return { + ...toAuthResult(orgResult.response), + organizationName: orgResult.organizationName, + }; + } + } + + handleWorkOSError(error, "Failed to authenticate with OAuth"); + } + }, +); diff --git a/ts/mobile-starter/backend/auth/org-auth.ts b/ts/mobile-starter/backend/auth/org-auth.ts new file mode 100644 index 00000000..ff8713fe --- /dev/null +++ b/ts/mobile-starter/backend/auth/org-auth.ts @@ -0,0 +1,98 @@ +import type { AuthenticationResponse } from "@workos-inc/node"; +import { secret } from "encore.dev/config"; +import log from "encore.dev/log"; +import { getWorkOS } from "./auth"; +import { getPendingToken, isOauthException } from "./auth.helpers"; + +const workOSClientId = secret("WorkOSClientId"); + +type OrgAuthResult = { + response: AuthenticationResponse; + organizationName: string; +}; + +async function selectFirstOrg( + userId: string, + pendingToken: string, +): Promise { + const workos = getWorkOS(); + const memberships = await workos.userManagement.listOrganizationMemberships({ + userId, + statuses: ["active"], + }); + + if (memberships.data.length === 0) { + log.info("resolveOrgAuth: no active memberships", { userId }); + return null; + } + + if (memberships.data.length > 1) { + log.info( + "resolveOrgAuth: multiple memberships, deferring to org selection", + { userId, count: memberships.data.length }, + ); + return null; + } + + const membership = memberships.data[0]; + log.info("resolveOrgAuth: selecting org", { + userId, + organizationId: membership.organizationId, + role: membership.role?.slug, + }); + + const response = + await workos.userManagement.authenticateWithOrganizationSelection({ + clientId: workOSClientId(), + organizationId: membership.organizationId, + pendingAuthenticationToken: pendingToken, + }); + return { response, organizationName: membership.organizationName }; +} + +export async function resolveOrgAuth( + email: string, + pendingToken: string, +): Promise { + const users = await getWorkOS().userManagement.listUsers({ email }); + const user = users.data[0]; + if (!user) { + log.warn("resolveOrgAuth: user not found by email", { email }); + return null; + } + return selectFirstOrg(user.id, pendingToken); +} + +export async function resolveOrgAuthByUserId( + userId: string, + pendingToken: string, +): Promise { + return selectFirstOrg(userId, pendingToken); +} + +export async function getOrgName( + response: AuthenticationResponse, +): Promise { + if (!response.organizationId) return undefined; + try { + const org = await getWorkOS().organizations.getOrganization( + response.organizationId, + ); + return org.name; + } catch { + return undefined; + } +} + +export function isOrgSelectionRequired( + error: unknown, +): { pendingToken: string; userId?: string } | null { + if (!isOauthException(error)) return null; + if (error.error !== "organization_selection_required") return null; + const token = getPendingToken(error); + if (!token) return null; + const userId = (error.rawData?.user as Record)?.id as + | string + | undefined; + return { pendingToken: token, userId }; +} diff --git a/ts/mobile-starter/backend/auth/password-reset.ts b/ts/mobile-starter/backend/auth/password-reset.ts new file mode 100644 index 00000000..9ddfeacf --- /dev/null +++ b/ts/mobile-starter/backend/auth/password-reset.ts @@ -0,0 +1,63 @@ +import { api } from "encore.dev/api"; +import { getWorkOS } from "./auth"; +import { handleWorkOSError } from "./auth.helpers"; + +interface ForgotPasswordRequest { + email: string; + passwordResetUrl: string; +} + +interface ForgotPasswordResponse { + success: true; +} + +export const forgotPassword = api( + { + expose: true, + auth: false, + method: "POST", + path: "/api/auth/forgot-password", + }, + async (req: ForgotPasswordRequest): Promise => { + try { + await getWorkOS().userManagement.sendPasswordResetEmail({ + email: req.email, + passwordResetUrl: req.passwordResetUrl, + }); + } catch { + // Always return success to prevent email enumeration + } + + return { success: true }; + }, +); + +interface ResetPasswordRequest { + token: string; + newPassword: string; +} + +interface ResetPasswordResponse { + success: true; +} + +export const resetPassword = api( + { + expose: true, + auth: false, + method: "POST", + path: "/api/auth/reset-password", + }, + async (req: ResetPasswordRequest): Promise => { + try { + await getWorkOS().userManagement.resetPassword({ + token: req.token, + newPassword: req.newPassword, + }); + + return { success: true }; + } catch (error: unknown) { + handleWorkOSError(error, "Failed to reset password"); + } + }, +); diff --git a/ts/mobile-starter/backend/auth/permissions.ts b/ts/mobile-starter/backend/auth/permissions.ts new file mode 100644 index 00000000..8744a089 --- /dev/null +++ b/ts/mobile-starter/backend/auth/permissions.ts @@ -0,0 +1,39 @@ +export const P = { + DASHBOARD_VIEW: "dashboard:view", + PROFILE_VIEW: "profile:view", + PROFILE_EDIT: "profile:edit", + MEMBERS_VIEW: "members:view", + MEMBERS_INVITE: "members:invite", + MEMBERS_REMOVE: "members:remove", +} as const; + +export type Permission = (typeof P)[keyof typeof P]; + +export type RoleType = "admin" | "member"; + +const VALID_ROLES = new Set(["admin", "member"]); + +export function mapRole(slug: string): RoleType { + return VALID_ROLES.has(slug) ? (slug as RoleType) : "member"; +} + +export const ROLE_PERMISSIONS: Record> = { + admin: new Set([ + P.DASHBOARD_VIEW, + P.PROFILE_VIEW, + P.PROFILE_EDIT, + P.MEMBERS_VIEW, + P.MEMBERS_INVITE, + P.MEMBERS_REMOVE, + ]), + member: new Set([ + P.DASHBOARD_VIEW, + P.PROFILE_VIEW, + P.PROFILE_EDIT, + P.MEMBERS_VIEW, + ]), +}; + +export function hasPermission(role: RoleType, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.has(permission) ?? false; +} diff --git a/ts/mobile-starter/backend/auth/refresh.ts b/ts/mobile-starter/backend/auth/refresh.ts new file mode 100644 index 00000000..7107cd2c --- /dev/null +++ b/ts/mobile-starter/backend/auth/refresh.ts @@ -0,0 +1,41 @@ +import { api } from "encore.dev/api"; +import { secret } from "encore.dev/config"; +import { getWorkOS } from "./auth"; +import { handleWorkOSError, toAuthResult, type UserInfo } from "./auth.helpers"; +import { getOrgName } from "./org-auth"; + +const workOSClientId = secret("WorkOSClientId"); + +interface RefreshRequest { + refreshToken: string; +} + +interface RefreshResponse { + accessToken: string; + refreshToken: string; + user: UserInfo; + organizationName?: string; +} + +export const refresh = api( + { + expose: true, + auth: false, + method: "POST", + path: "/api/auth/refresh", + }, + async (req: RefreshRequest): Promise => { + try { + const response = + await getWorkOS().userManagement.authenticateWithRefreshToken({ + clientId: workOSClientId(), + refreshToken: req.refreshToken, + }); + + const organizationName = await getOrgName(response); + return { ...toAuthResult(response), organizationName }; + } catch (error: unknown) { + handleWorkOSError(error, "Failed to refresh token"); + } + }, +); diff --git a/ts/mobile-starter/backend/auth/session.ts b/ts/mobile-starter/backend/auth/session.ts new file mode 100644 index 00000000..9f41decf --- /dev/null +++ b/ts/mobile-starter/backend/auth/session.ts @@ -0,0 +1,29 @@ +import { api } from "encore.dev/api"; +import { getAuthData } from "~encore/auth"; +import { mapRole, type RoleType } from "./permissions"; + +interface SessionResponse { + userID: string; + email: string; + role: RoleType; + organizationId: string; +} + +export const getSession = api( + { + expose: true, + auth: true, + method: "GET", + path: "/api/auth/session", + }, + async (): Promise => { + const authData = getAuthData()!; + + return { + userID: authData.userID, + email: authData.email, + role: mapRole(authData.role), + organizationId: authData.organizationId, + }; + }, +); diff --git a/ts/mobile-starter/backend/auth/sign-in.ts b/ts/mobile-starter/backend/auth/sign-in.ts new file mode 100644 index 00000000..f913143f --- /dev/null +++ b/ts/mobile-starter/backend/auth/sign-in.ts @@ -0,0 +1,89 @@ +import { api } from "encore.dev/api"; +import { secret } from "encore.dev/config"; +import { getWorkOS } from "./auth"; +import { + getPendingToken, + handleWorkOSError, + isOauthException, + toAuthResult, + type UserInfo, +} from "./auth.helpers"; +import { getOrgName, isOrgSelectionRequired, resolveOrgAuth } from "./org-auth"; + +const workOSClientId = secret("WorkOSClientId"); + +interface SignInRequest { + email: string; + password: string; +} + +interface SignInResponse { + status: "complete" | "mfa_required" | "verify_email"; + accessToken?: string; + refreshToken?: string; + user?: UserInfo; + pendingAuthenticationToken?: string; + organizationName?: string; +} + +function pendingResponse( + status: "mfa_required" | "verify_email", + pendingAuthenticationToken?: string, +): SignInResponse { + return { status, pendingAuthenticationToken }; +} + +async function authenticateWithPassword( + email: string, + password: string, +): Promise { + try { + const response = await getWorkOS().userManagement.authenticateWithPassword({ + clientId: workOSClientId(), + email, + password, + }); + + const organizationName = await getOrgName(response); + return { status: "complete", ...toAuthResult(response), organizationName }; + } catch (error: unknown) { + const orgSelection = isOrgSelectionRequired(error); + if (orgSelection) { + const orgResult = await resolveOrgAuth(email, orgSelection.pendingToken); + if (orgResult) { + return { + status: "complete", + ...toAuthResult(orgResult.response), + organizationName: orgResult.organizationName, + }; + } + } + + if (isOauthException(error)) { + const token = getPendingToken(error); + if (error.error === "mfa_enrollment") { + return pendingResponse("mfa_required", token); + } + if (error.error === "email_verification_required") { + return pendingResponse("verify_email", token); + } + } + throw error; + } +} + +export const signIn = api( + { + expose: true, + auth: false, + method: "POST", + path: "/api/auth/sign-in", + }, + async (req: SignInRequest): Promise => { + try { + return await authenticateWithPassword(req.email, req.password); + } catch (error: unknown) { + handleWorkOSError(error, "Failed to sign in"); + } + }, +); diff --git a/ts/mobile-starter/backend/auth/sign-out.ts b/ts/mobile-starter/backend/auth/sign-out.ts new file mode 100644 index 00000000..9d19c494 --- /dev/null +++ b/ts/mobile-starter/backend/auth/sign-out.ts @@ -0,0 +1,21 @@ +import { api } from "encore.dev/api"; + +interface SignOutRequest { + sessionId?: string; +} + +interface SignOutResponse { + success: true; +} + +export const signOut = api( + { + expose: true, + auth: false, + method: "POST", + path: "/api/auth/sign-out", + }, + async (_req: SignOutRequest): Promise => { + return { success: true }; + }, +); diff --git a/ts/mobile-starter/backend/auth/sign-up.ts b/ts/mobile-starter/backend/auth/sign-up.ts new file mode 100644 index 00000000..a3d2dbe6 --- /dev/null +++ b/ts/mobile-starter/backend/auth/sign-up.ts @@ -0,0 +1,142 @@ +import { api } from "encore.dev/api"; +import { secret } from "encore.dev/config"; +import log from "encore.dev/log"; +import { getWorkOS } from "./auth"; +import { + getPendingToken, + handleWorkOSError, + isOauthException, + toAuthResult, + type UserInfo, +} from "./auth.helpers"; +import { getOrgName, isOrgSelectionRequired, resolveOrgAuth } from "./org-auth"; + +const workOSClientId = secret("WorkOSClientId"); + +interface SignUpRequest { + email: string; + password: string; + firstName: string; + lastName: string; +} + +interface SignUpResponse { + status: "complete" | "verify_email"; + accessToken?: string; + refreshToken?: string; + user?: UserInfo; + userId?: string; + pendingAuthenticationToken?: string; + organizationName?: string; +} + +async function createUser(req: SignUpRequest) { + return getWorkOS().userManagement.createUser({ + email: req.email, + password: req.password, + firstName: req.firstName, + lastName: req.lastName, + }); +} + +function isEmailVerificationRequired(error: unknown): boolean { + if ( + isOauthException(error) && + error.error === "email_verification_required" + ) { + return true; + } + if ( + error instanceof Error && + "status" in error && + (error as Error & { status?: number }).status === 403 && + error.message.includes("verified before authentication") + ) { + return true; + } + return false; +} + +function extractPendingToken(error: unknown): string | undefined { + if (isOauthException(error)) { + return getPendingToken(error); + } + if (error instanceof Error && "rawData" in error) { + const rawData = (error as Error & { rawData?: Record }) + .rawData; + return rawData?.pending_authentication_token as string | undefined; + } + return undefined; +} + +async function authenticateNewUser( + email: string, + password: string, + userId: string, +): Promise { + try { + const response = await getWorkOS().userManagement.authenticateWithPassword({ + clientId: workOSClientId(), + email, + password, + }); + + const organizationName = await getOrgName(response); + return { status: "complete", ...toAuthResult(response), organizationName }; + } catch (error: unknown) { + const orgSelection = isOrgSelectionRequired(error); + if (orgSelection) { + const orgResult = await resolveOrgAuth(email, orgSelection.pendingToken); + if (orgResult) { + return { + status: "complete", + ...toAuthResult(orgResult.response), + organizationName: orgResult.organizationName, + }; + } + } + + if (isEmailVerificationRequired(error)) { + const pendingToken = extractPendingToken(error); + log.info("signUp: email verification required", { + userId, + hasPendingToken: !!pendingToken, + }); + return { + status: "verify_email", + userId, + pendingAuthenticationToken: pendingToken, + }; + } + throw error; + } +} + +export const signUp = api( + { + expose: true, + auth: false, + method: "POST", + path: "/api/auth/sign-up", + }, + async (req: SignUpRequest): Promise => { + try { + const user = await createUser(req); + return await authenticateNewUser(req.email, req.password, user.id); + } catch (error: unknown) { + if ( + error instanceof Error && + "status" in error && + (error as Error & { status?: number }).status === 409 + ) { + try { + return await authenticateNewUser(req.email, req.password, "existing"); + } catch (authError: unknown) { + handleWorkOSError(authError, "Failed to create account"); + } + } + + handleWorkOSError(error, "Failed to create account"); + } + }, +); diff --git a/ts/mobile-starter/backend/auth/verify-email.ts b/ts/mobile-starter/backend/auth/verify-email.ts new file mode 100644 index 00000000..de332a9e --- /dev/null +++ b/ts/mobile-starter/backend/auth/verify-email.ts @@ -0,0 +1,71 @@ +import { api } from "encore.dev/api"; +import { secret } from "encore.dev/config"; +import { getWorkOS } from "./auth"; +import { handleWorkOSError, toAuthResult, type UserInfo } from "./auth.helpers"; +import { + getOrgName, + isOrgSelectionRequired, + resolveOrgAuthByUserId, +} from "./org-auth"; + +const workOSClientId = secret("WorkOSClientId"); + +interface VerifyEmailRequest { + code: string; + pendingAuthenticationToken: string; +} + +interface VerifyEmailResponse { + accessToken: string; + refreshToken: string; + user: UserInfo; + organizationName?: string; +} + +async function authenticateWithVerificationCode( + code: string, + pendingAuthenticationToken: string, +): Promise { + const response = + await getWorkOS().userManagement.authenticateWithEmailVerification({ + clientId: workOSClientId(), + code, + pendingAuthenticationToken, + }); + + const organizationName = await getOrgName(response); + return { ...toAuthResult(response), organizationName }; +} + +export const verifyEmail = api( + { + expose: true, + auth: false, + method: "POST", + path: "/api/auth/verify-email", + }, + async (req: VerifyEmailRequest): Promise => { + try { + return await authenticateWithVerificationCode( + req.code, + req.pendingAuthenticationToken, + ); + } catch (error: unknown) { + const orgSelection = isOrgSelectionRequired(error); + if (orgSelection?.userId) { + const orgResult = await resolveOrgAuthByUserId( + orgSelection.userId, + orgSelection.pendingToken, + ); + if (orgResult) { + return { + ...toAuthResult(orgResult.response), + organizationName: orgResult.organizationName, + }; + } + } + + handleWorkOSError(error, "Failed to verify email"); + } + }, +); diff --git a/ts/mobile-starter/backend/package.json b/ts/mobile-starter/backend/package.json new file mode 100644 index 00000000..f7be09e3 --- /dev/null +++ b/ts/mobile-starter/backend/package.json @@ -0,0 +1,11 @@ +{ + "name": "backend", + "private": true, + "version": "0.0.1", + "type": "module", + "dependencies": { + "@workos-inc/node": "^7.0.0", + "encore.dev": "^1.44.0", + "jose": "^6.0.0" + } +} diff --git a/ts/mobile-starter/biome.json b/ts/mobile-starter/biome.json new file mode 100644 index 00000000..97061324 --- /dev/null +++ b/ts/mobile-starter/biome.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "files": { + "ignore": ["node_modules", "dist", "encore.gen", ".encore", "*.gen.ts"] + } +} diff --git a/ts/mobile-starter/encore.app b/ts/mobile-starter/encore.app new file mode 100644 index 00000000..ac33af5c --- /dev/null +++ b/ts/mobile-starter/encore.app @@ -0,0 +1,4 @@ +{ + "id": "", + "lang": "typescript" +} diff --git a/ts/mobile-starter/native/app.json b/ts/mobile-starter/native/app.json new file mode 100644 index 00000000..184bc68e --- /dev/null +++ b/ts/mobile-starter/native/app.json @@ -0,0 +1,13 @@ +{ + "expo": { + "scheme": "mobile-starter", + "userInterfaceStyle": "automatic", + "orientation": "portrait", + "name": "Mobile Starter", + "slug": "mobile-starter", + "plugins": ["expo-router", "expo-secure-store"], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/ts/mobile-starter/native/app/(tabs)/_layout.tsx b/ts/mobile-starter/native/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..16e11081 --- /dev/null +++ b/ts/mobile-starter/native/app/(tabs)/_layout.tsx @@ -0,0 +1,46 @@ +import { Tabs } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { AuthGuard } from "@/features/auth/components/auth-guard"; + +export default function TabLayout() { + return ( + + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + + ); +} diff --git a/ts/mobile-starter/native/app/(tabs)/index.tsx b/ts/mobile-starter/native/app/(tabs)/index.tsx new file mode 100644 index 00000000..89d8219a --- /dev/null +++ b/ts/mobile-starter/native/app/(tabs)/index.tsx @@ -0,0 +1,48 @@ +import { ScrollView, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useAuth } from "@/features/auth/providers/auth-provider"; + +export default function DashboardScreen() { + const { user, role, organizationName, email } = useAuth(); + + const name = + `${user?.firstName ?? ""} ${user?.lastName ?? ""}`.trim() || + email || + "User"; + + return ( + + + Dashboard + + Welcome back, {name} + + + + + + + + ); +} + +function InfoCard({ title, value }: { title: string; value: string }) { + return ( + + + {title} + + + {value} + + + ); +} diff --git a/ts/mobile-starter/native/app/(tabs)/members.tsx b/ts/mobile-starter/native/app/(tabs)/members.tsx new file mode 100644 index 00000000..9b8e9ce9 --- /dev/null +++ b/ts/mobile-starter/native/app/(tabs)/members.tsx @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + FlatList, + Pressable, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useAuth } from "@/features/auth/providers/auth-provider"; +import { apiClient } from "@/lib/api/client"; +import { hasPermission, P } from "@/lib/permissions"; +import type { auth } from "@/lib/api/client.gen"; + +export default function MembersScreen() { + const { role } = useAuth(); + const canInvite = hasPermission(role, P.MEMBERS_INVITE); + const canRemove = hasPermission(role, P.MEMBERS_REMOVE); + + const [invitations, setInvitations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState<"admin" | "member">("member"); + const [isSending, setIsSending] = useState(false); + + const loadInvitations = useCallback(async () => { + try { + const result = await apiClient.auth.listInvitations(); + setInvitations(result.invitations); + } catch { + // Error handled silently + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadInvitations(); + }, [loadInvitations]); + + const handleInvite = async () => { + if (!inviteEmail) { + Alert.alert("Error", "Please enter an email address"); + return; + } + + setIsSending(true); + try { + await apiClient.auth.sendInvitation({ + email: inviteEmail, + role: inviteRole, + }); + setInviteEmail(""); + Alert.alert("Success", `Invitation sent to ${inviteEmail}`); + loadInvitations(); + } catch (err) { + Alert.alert( + "Error", + err instanceof Error ? err.message : "Failed to send invitation", + ); + } finally { + setIsSending(false); + } + }; + + const handleRevoke = async (id: string) => { + Alert.alert("Revoke Invitation", "Are you sure?", [ + { text: "Cancel", style: "cancel" }, + { + text: "Revoke", + style: "destructive", + onPress: async () => { + try { + await apiClient.auth.revokeInvitation({ id }); + loadInvitations(); + } catch { + Alert.alert("Error", "Failed to revoke invitation"); + } + }, + }, + ]); + }; + + return ( + + + Members + + {canInvite && ( + + + Invite a member + + + + setInviteRole("member")} + style={{ + flex: 1, + padding: 10, + borderRadius: 8, + borderWidth: 1, + borderColor: + inviteRole === "member" ? "#2563eb" : "#e5e7eb", + backgroundColor: + inviteRole === "member" ? "#eff6ff" : "#fff", + alignItems: "center", + }} + > + + Member + + + setInviteRole("admin")} + style={{ + flex: 1, + padding: 10, + borderRadius: 8, + borderWidth: 1, + borderColor: + inviteRole === "admin" ? "#2563eb" : "#e5e7eb", + backgroundColor: + inviteRole === "admin" ? "#eff6ff" : "#fff", + alignItems: "center", + }} + > + + Admin + + + + + + {isSending ? "Sending..." : "Send Invitation"} + + + + )} + + + Pending Invitations + + + {isLoading ? ( + Loading... + ) : invitations.length === 0 ? ( + No pending invitations. + ) : ( + item.id} + renderItem={({ item }) => ( + + + {item.email} + + {item.status} + + + {canRemove && ( + handleRevoke(item.id)}> + + Revoke + + + )} + + )} + /> + )} + + + ); +} diff --git a/ts/mobile-starter/native/app/(tabs)/profile.tsx b/ts/mobile-starter/native/app/(tabs)/profile.tsx new file mode 100644 index 00000000..2dc32909 --- /dev/null +++ b/ts/mobile-starter/native/app/(tabs)/profile.tsx @@ -0,0 +1,89 @@ +import { Pressable, ScrollView, Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useAuth } from "@/features/auth/providers/auth-provider"; + +export default function ProfileScreen() { + const { user, role, organizationName, signOut } = useAuth(); + + const name = + `${user?.firstName ?? ""} ${user?.lastName ?? ""}`.trim() || + user?.email || + "User"; + + return ( + + + Profile + + + + + + + + + + + + Sign Out + + + + + ); +} + +function ProfileRow({ + label, + value, + capitalize, + mono, +}: { + label: string; + value: string; + capitalize?: boolean; + mono?: boolean; +}) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/ts/mobile-starter/native/app/+not-found.tsx b/ts/mobile-starter/native/app/+not-found.tsx new file mode 100644 index 00000000..7de30582 --- /dev/null +++ b/ts/mobile-starter/native/app/+not-found.tsx @@ -0,0 +1,18 @@ +import { Link, Stack } from "expo-router"; +import { Text, View } from "react-native"; + +export default function NotFoundScreen() { + return ( + <> + + + + This screen doesn't exist. + + + Go to home screen + + + + ); +} diff --git a/ts/mobile-starter/native/app/_layout.tsx b/ts/mobile-starter/native/app/_layout.tsx new file mode 100644 index 00000000..7f04dfc4 --- /dev/null +++ b/ts/mobile-starter/native/app/_layout.tsx @@ -0,0 +1,19 @@ +import "@/global.css"; + +import { Stack } from "expo-router"; +import { AuthProvider } from "@/features/auth/providers/auth-provider"; + +export const unstable_settings = { + initialRouteName: "index", +}; + +export default function Layout() { + return ( + + + + + + + ); +} diff --git a/ts/mobile-starter/native/app/index.tsx b/ts/mobile-starter/native/app/index.tsx new file mode 100644 index 00000000..155b5fef --- /dev/null +++ b/ts/mobile-starter/native/app/index.tsx @@ -0,0 +1,71 @@ +import { router } from "expo-router"; +import { useEffect, useState } from "react"; +import { + KeyboardAvoidingView, + Platform, + ScrollView, + Text, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useAuth } from "@/features/auth/providers/auth-provider"; +import { LoginForm } from "@/features/auth/components/login-form"; +import { SignUpForm } from "@/features/auth/components/signup-form"; +import { OAuthButtons } from "@/features/auth/components/oauth-buttons"; + +export default function LandingScreen() { + const { isAuthenticated, isLoading } = useAuth(); + const [mode, setMode] = useState<"login" | "signup">("login"); + + useEffect(() => { + if (isLoading) return; + if (isAuthenticated) { + router.replace("/(tabs)"); + } + }, [isAuthenticated, isLoading]); + + if (isLoading) { + return ( + + Loading... + + ); + } + + const handleSuccess = () => { + router.replace("/(tabs)"); + }; + + return ( + + + + + + {mode === "login" ? ( + setMode("signup")} + /> + ) : ( + setMode("login")} + /> + )} + + + + ); +} diff --git a/ts/mobile-starter/native/features/auth/components/auth-guard.tsx b/ts/mobile-starter/native/features/auth/components/auth-guard.tsx new file mode 100644 index 00000000..967f5f82 --- /dev/null +++ b/ts/mobile-starter/native/features/auth/components/auth-guard.tsx @@ -0,0 +1,33 @@ +import { router } from "expo-router"; +import { useEffect } from "react"; +import { ActivityIndicator, View } from "react-native"; +import { useAuth } from "../providers/auth-provider"; + +interface AuthGuardProps { + children: React.ReactNode; +} + +export function AuthGuard({ children }: AuthGuardProps) { + const { isAuthenticated, isLoading } = useAuth(); + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.replace("/"); + } + }, [isAuthenticated, isLoading]); + + if (isLoading) { + return ( + + + + ); + } + + if (!isAuthenticated) { + return null; + } + + return <>{children}; +} diff --git a/ts/mobile-starter/native/features/auth/components/login-form.tsx b/ts/mobile-starter/native/features/auth/components/login-form.tsx new file mode 100644 index 00000000..2a38a867 --- /dev/null +++ b/ts/mobile-starter/native/features/auth/components/login-form.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { + Alert, + Pressable, + Text, + TextInput, + View, +} from "react-native"; +import { useAuth } from "../providers/auth-provider"; + +interface LoginFormProps { + onSuccess?: () => void; + onSwitchToSignUp?: () => void; +} + +export function LoginForm({ onSuccess, onSwitchToSignUp }: LoginFormProps) { + const { signIn } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + if (!email || !password) { + Alert.alert("Error", "Please fill in all fields"); + return; + } + + setIsLoading(true); + try { + const result = await signIn(email, password); + if (result.status === "complete") { + onSuccess?.(); + } else if (result.status === "verify_email") { + Alert.alert("Verify Email", "Please verify your email before signing in."); + } + } catch (err) { + Alert.alert( + "Sign In Failed", + err instanceof Error ? err.message : "Invalid email or password", + ); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Welcome back + + + Sign in to your account + + + + + + + + + {isLoading ? "Signing in..." : "Sign In"} + + + + + + Don't have an account? Sign up + + + + ); +} diff --git a/ts/mobile-starter/native/features/auth/components/oauth-buttons.tsx b/ts/mobile-starter/native/features/auth/components/oauth-buttons.tsx new file mode 100644 index 00000000..4a859156 --- /dev/null +++ b/ts/mobile-starter/native/features/auth/components/oauth-buttons.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import * as WebBrowser from "expo-web-browser"; +import * as Linking from "expo-linking"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "../providers/auth-provider"; +import type { auth } from "@/lib/api/client.gen"; + +export function OAuthButtons() { + const { storeSession } = useAuth(); + const [loadingProvider, setLoadingProvider] = + useState(null); + + const handleOAuth = async (provider: auth.OAuthProvider) => { + setLoadingProvider(provider); + try { + const redirectUri = Linking.createURL("auth/callback"); + const { url } = await apiClient.auth.getOAuthUrl({ + provider, + redirectUri, + }); + + const result = await WebBrowser.openAuthSessionAsync(url, redirectUri); + + if (result.type === "success" && result.url) { + const parsed = Linking.parse(result.url); + const code = parsed.queryParams?.code as string | undefined; + if (code) { + const authResult = await apiClient.auth.oauthCallback({ code }); + await storeSession(authResult); + } + } + } catch { + // User cancelled or error occurred + } finally { + setLoadingProvider(null); + } + }; + + const buttonStyle = { + flexDirection: "row" as const, + alignItems: "center" as const, + justifyContent: "center" as const, + gap: 8, + borderWidth: 1, + borderColor: "#e5e7eb", + borderRadius: 8, + padding: 12, + backgroundColor: "#fff", + }; + + return ( + + handleOAuth("GoogleOAuth")} + disabled={loadingProvider !== null} + style={buttonStyle} + > + + {loadingProvider === "GoogleOAuth" + ? "Redirecting..." + : "Continue with Google"} + + + + handleOAuth("MicrosoftOAuth")} + disabled={loadingProvider !== null} + style={buttonStyle} + > + + {loadingProvider === "MicrosoftOAuth" + ? "Redirecting..." + : "Continue with Microsoft"} + + + + + + + or + + + + + ); +} diff --git a/ts/mobile-starter/native/features/auth/components/signup-form.tsx b/ts/mobile-starter/native/features/auth/components/signup-form.tsx new file mode 100644 index 00000000..2c820403 --- /dev/null +++ b/ts/mobile-starter/native/features/auth/components/signup-form.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { + Alert, + Pressable, + Text, + TextInput, + View, +} from "react-native"; +import { useAuth } from "../providers/auth-provider"; + +interface SignUpFormProps { + onSuccess?: () => void; + onSwitchToLogin?: () => void; +} + +export function SignUpForm({ onSuccess, onSwitchToLogin }: SignUpFormProps) { + const { signUp } = useAuth(); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + if (!firstName || !lastName || !email || !password) { + Alert.alert("Error", "Please fill in all fields"); + return; + } + if (password.length < 8) { + Alert.alert("Error", "Password must be at least 8 characters"); + return; + } + + setIsLoading(true); + try { + const result = await signUp(email, password, firstName, lastName); + if (result.status === "complete") { + onSuccess?.(); + } else if (result.status === "verify_email") { + Alert.alert( + "Verify Email", + "Please check your inbox for a verification email.", + ); + } + } catch (err) { + Alert.alert( + "Sign Up Failed", + err instanceof Error ? err.message : "Something went wrong", + ); + } finally { + setIsLoading(false); + } + }; + + const inputStyle = { + borderWidth: 1, + borderColor: "#e5e7eb", + borderRadius: 8, + padding: 12, + fontSize: 16, + backgroundColor: "#fff", + } as const; + + return ( + + + Create account + + + Get started with your new account + + + + + + + + + + + + + + {isLoading ? "Creating account..." : "Create Account"} + + + + + + Already have an account? Sign in + + + + ); +} diff --git a/ts/mobile-starter/native/features/auth/providers/auth-provider.tsx b/ts/mobile-starter/native/features/auth/providers/auth-provider.tsx new file mode 100644 index 00000000..9e457729 --- /dev/null +++ b/ts/mobile-starter/native/features/auth/providers/auth-provider.tsx @@ -0,0 +1,200 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import * as SecureStore from "expo-secure-store"; +import { apiClient } from "@/lib/api/client"; +import type { auth } from "@/lib/api/client.gen"; +import { decodeJwtPayload, getRefreshDelay } from "@/lib/token-utils"; +import { mapRole, type RoleType } from "@/lib/permissions"; +import type { AuthContextType, AuthState } from "./types"; + +const AuthContext = createContext(null); + +const initialState: AuthState = { + isAuthenticated: false, + isLoading: true, + user: null, + accessToken: null, + role: "member", + organizationId: "", + organizationName: "", +}; + +function extractRoleAndOrg(token: string): { + role: RoleType; + organizationId: string; +} { + const payload = decodeJwtPayload(token); + if (!payload) + return { role: "member", organizationId: "" }; + return { + role: mapRole((payload.role as string) || undefined), + organizationId: (payload.org_id as string) ?? "", + }; +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState(initialState); + const refreshTimerRef = useRef | undefined>( + undefined, + ); + + const updateAuth = useCallback( + async (result: auth.RefreshResponse & { organizationName?: string }) => { + await SecureStore.setItemAsync("accessToken", result.accessToken); + await SecureStore.setItemAsync("refreshToken", result.refreshToken); + if (result.organizationName) { + await SecureStore.setItemAsync( + "organizationName", + result.organizationName, + ); + } + + const { role, organizationId } = extractRoleAndOrg(result.accessToken); + const orgName = + result.organizationName ?? + (await SecureStore.getItemAsync("organizationName")) ?? + organizationId; + + setState({ + isAuthenticated: true, + isLoading: false, + user: result.user, + accessToken: result.accessToken, + role, + organizationId, + organizationName: orgName, + }); + + // Schedule refresh + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + const delay = getRefreshDelay(result.accessToken); + refreshTimerRef.current = setTimeout(async () => { + try { + const refreshToken = await SecureStore.getItemAsync("refreshToken"); + if (!refreshToken) return; + const refreshResult = await apiClient.auth.refresh({ refreshToken }); + await updateAuth(refreshResult); + } catch { + await clearAuth(); + } + }, delay); + }, + [], + ); + + const clearAuth = useCallback(async () => { + await SecureStore.deleteItemAsync("accessToken"); + await SecureStore.deleteItemAsync("refreshToken"); + await SecureStore.deleteItemAsync("organizationName"); + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + setState({ ...initialState, isLoading: false }); + }, []); + + // Bootstrap: try to restore session from SecureStore + useEffect(() => { + const bootstrap = async () => { + const refreshToken = await SecureStore.getItemAsync("refreshToken"); + if (!refreshToken) { + setState((prev) => ({ ...prev, isLoading: false })); + return; + } + + try { + const result = await apiClient.auth.refresh({ refreshToken }); + await updateAuth(result); + } catch { + await SecureStore.deleteItemAsync("refreshToken"); + setState((prev) => ({ ...prev, isLoading: false })); + } + }; + + bootstrap(); + + return () => { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + }; + }, [updateAuth]); + + const signIn = useCallback( + async (email: string, password: string) => { + const result = await apiClient.auth.signIn({ email, password }); + if ( + result.status === "complete" && + result.accessToken && + result.refreshToken && + result.user + ) { + await updateAuth({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + user: result.user, + organizationName: result.organizationName, + }); + } + return result; + }, + [updateAuth], + ); + + const signUp = useCallback( + async ( + email: string, + password: string, + firstName: string, + lastName: string, + ) => { + const result = await apiClient.auth.signUp({ + email, + password, + firstName, + lastName, + }); + if ( + result.status === "complete" && + result.accessToken && + result.refreshToken && + result.user + ) { + await updateAuth({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + user: result.user, + organizationName: result.organizationName, + }); + } + return result; + }, + [updateAuth], + ); + + const signOut = useCallback(async () => { + apiClient.auth.signOut({}).catch(() => {}); + await clearAuth(); + }, [clearAuth]); + + const value = useMemo( + () => ({ + ...state, + signIn, + signUp, + signOut, + storeSession: updateAuth, + }), + [state, signIn, signUp, signOut, updateAuth], + ); + + return {children}; +} + +export function useAuth(): AuthContextType { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/ts/mobile-starter/native/features/auth/providers/types.ts b/ts/mobile-starter/native/features/auth/providers/types.ts new file mode 100644 index 00000000..25def63a --- /dev/null +++ b/ts/mobile-starter/native/features/auth/providers/types.ts @@ -0,0 +1,24 @@ +import type { auth } from "@/lib/api/client.gen"; +import type { RoleType } from "@/lib/permissions"; + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: auth.UserInfo | null; + accessToken: string | null; + role: RoleType; + organizationId: string; + organizationName: string; +} + +export interface AuthContextType extends AuthState { + signIn: (email: string, password: string) => Promise; + signUp: ( + email: string, + password: string, + firstName: string, + lastName: string, + ) => Promise; + signOut: () => Promise; + storeSession: (result: auth.RefreshResponse) => Promise; +} diff --git a/ts/mobile-starter/native/global.css b/ts/mobile-starter/native/global.css new file mode 100644 index 00000000..b5c61c95 --- /dev/null +++ b/ts/mobile-starter/native/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/ts/mobile-starter/native/lib/api/client.gen.ts b/ts/mobile-starter/native/lib/api/client.gen.ts new file mode 100644 index 00000000..39be322a --- /dev/null +++ b/ts/mobile-starter/native/lib/api/client.gen.ts @@ -0,0 +1,142 @@ +/** + * Placeholder API client for React Native. + * + * This file will be regenerated by Encore after running: + * task gen:api:native + * + * For now, this provides type stubs so the app compiles. + */ + +export const Local = "http://localhost:4000"; + +// biome-ignore lint/suspicious/noExplicitAny: placeholder types +type Any = any; + +export namespace auth { + export type OAuthProvider = "GoogleOAuth" | "MicrosoftOAuth"; + + export interface UserInfo { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + profilePictureUrl: string | null; + } + + export interface SignInResponse { + status: "complete" | "mfa_required" | "verify_email"; + accessToken?: string; + refreshToken?: string; + user?: UserInfo; + pendingAuthenticationToken?: string; + organizationName?: string; + } + + export interface SignUpResponse { + status: "complete" | "verify_email"; + accessToken?: string; + refreshToken?: string; + user?: UserInfo; + userId?: string; + pendingAuthenticationToken?: string; + organizationName?: string; + } + + export interface RefreshResponse { + accessToken: string; + refreshToken: string; + user: UserInfo; + organizationName?: string; + } + + export interface SessionResponse { + userID: string; + email: string; + role: "admin" | "member"; + organizationId: string; + } + + export interface InvitationItem { + id: string; + email: string; + status: string; + createdAt: string; + expiresAt: string; + } +} + +interface AuthOptions { + auth?: () => Promise<{ authorization: string } | undefined>; +} + +export default class Client { + private baseUrl: string; + private opts: AuthOptions; + + constructor(baseUrl: string, opts: AuthOptions = {}) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.opts = opts; + } + + private async fetch(path: string, init?: RequestInit): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...(init?.headers as Record), + }; + const authResult = await this.opts.auth?.(); + if (authResult) { + headers.Authorization = authResult.authorization; + } + const resp = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers, + }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.message || `Request failed: ${resp.status}`); + } + const text = await resp.text(); + return text ? JSON.parse(text) : {}; + } + + auth = { + signIn: (req: { email: string; password: string }): Promise => + this.fetch("/api/auth/sign-in", { method: "POST", body: JSON.stringify(req) }), + + signUp: (req: { email: string; password: string; firstName: string; lastName: string }): Promise => + this.fetch("/api/auth/sign-up", { method: "POST", body: JSON.stringify(req) }), + + signOut: (req: { sessionId?: string }): Promise<{ success: true }> => + this.fetch("/api/auth/sign-out", { method: "POST", body: JSON.stringify(req) }), + + refresh: (req: { refreshToken: string }): Promise => + this.fetch("/api/auth/refresh", { method: "POST", body: JSON.stringify(req) }), + + getSession: (): Promise => + this.fetch("/api/auth/session"), + + verifyEmail: (req: { code: string; pendingAuthenticationToken: string }): Promise => + this.fetch("/api/auth/verify-email", { method: "POST", body: JSON.stringify(req) }), + + forgotPassword: (req: { email: string; passwordResetUrl: string }): Promise<{ success: true }> => + this.fetch("/api/auth/forgot-password", { method: "POST", body: JSON.stringify(req) }), + + resetPassword: (req: { token: string; newPassword: string }): Promise<{ success: true }> => + this.fetch("/api/auth/reset-password", { method: "POST", body: JSON.stringify(req) }), + + getOAuthUrl: (req: { provider: auth.OAuthProvider; redirectUri: string }): Promise<{ url: string }> => + this.fetch(`/api/auth/oauth/url?provider=${req.provider}&redirectUri=${encodeURIComponent(req.redirectUri)}`), + + oauthCallback: (req: { code: string }): Promise => + this.fetch("/api/auth/oauth/callback", { method: "POST", body: JSON.stringify(req) }), + + sendInvitation: (req: { email: string; role: string }): Promise<{ invitationId: string; email: string; status: string }> => + this.fetch("/api/auth/invitations", { method: "POST", body: JSON.stringify(req) }), + + listInvitations: (): Promise<{ invitations: auth.InvitationItem[] }> => + this.fetch("/api/auth/invitations"), + + revokeInvitation: (req: { id: string }): Promise => + this.fetch(`/api/auth/invitations/${req.id}`, { method: "DELETE" }), + }; +} diff --git a/ts/mobile-starter/native/lib/api/client.ts b/ts/mobile-starter/native/lib/api/client.ts new file mode 100644 index 00000000..8ea7b597 --- /dev/null +++ b/ts/mobile-starter/native/lib/api/client.ts @@ -0,0 +1,12 @@ +import Client, { Local } from "./client.gen"; +import * as SecureStore from "expo-secure-store"; + +const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? Local; + +export const apiClient = new Client(API_BASE_URL, { + auth: async () => { + const token = await SecureStore.getItemAsync("accessToken"); + if (token) return { authorization: `Bearer ${token}` }; + return undefined; + }, +}); diff --git a/ts/mobile-starter/native/lib/permissions.ts b/ts/mobile-starter/native/lib/permissions.ts new file mode 100644 index 00000000..57984b32 --- /dev/null +++ b/ts/mobile-starter/native/lib/permissions.ts @@ -0,0 +1,40 @@ +export const P = { + DASHBOARD_VIEW: "dashboard:view", + PROFILE_VIEW: "profile:view", + PROFILE_EDIT: "profile:edit", + MEMBERS_VIEW: "members:view", + MEMBERS_INVITE: "members:invite", + MEMBERS_REMOVE: "members:remove", +} as const; + +export type Permission = (typeof P)[keyof typeof P]; + +export type RoleType = "admin" | "member"; + +export const ROLE_PERMISSIONS: Record> = { + admin: new Set([ + P.DASHBOARD_VIEW, + P.PROFILE_VIEW, + P.PROFILE_EDIT, + P.MEMBERS_VIEW, + P.MEMBERS_INVITE, + P.MEMBERS_REMOVE, + ]), + member: new Set([ + P.DASHBOARD_VIEW, + P.PROFILE_VIEW, + P.PROFILE_EDIT, + P.MEMBERS_VIEW, + ]), +}; + +export function hasPermission(role: RoleType, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.has(permission) ?? false; +} + +const VALID_ROLES = new Set(["admin", "member"]); + +export function mapRole(slug?: string): RoleType { + if (!slug) return "member"; + return VALID_ROLES.has(slug) ? (slug as RoleType) : "member"; +} diff --git a/ts/mobile-starter/native/lib/token-utils.ts b/ts/mobile-starter/native/lib/token-utils.ts new file mode 100644 index 00000000..df3d36fa --- /dev/null +++ b/ts/mobile-starter/native/lib/token-utils.ts @@ -0,0 +1,34 @@ +export function decodeJwtPayload( + token: string, +): Record | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = JSON.parse(atob(parts[1])); + return payload; + } catch { + return null; + } +} + +export function isTokenExpiringSoon( + token: string, + bufferSeconds = 60, +): boolean { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return true; + + const nowSeconds = Math.floor(Date.now() / 1000); + return (payload.exp as number) - nowSeconds <= bufferSeconds; +} + +export function getTokenExpiryMs(token: string): number { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return 0; + return (payload.exp as number) * 1000; +} + +export function getRefreshDelay(accessToken: string): number { + const expiryMs = getTokenExpiryMs(accessToken); + return Math.max(0, expiryMs - Date.now() - 60_000); +} diff --git a/ts/mobile-starter/native/metro.config.cjs b/ts/mobile-starter/native/metro.config.cjs new file mode 100644 index 00000000..14d5c9ce --- /dev/null +++ b/ts/mobile-starter/native/metro.config.cjs @@ -0,0 +1,7 @@ +const { getDefaultConfig } = require("expo/metro-config"); +const { withNativeWind } = require("nativewind/metro"); + +/** @type {import('expo/metro-config').MetroConfig} */ +const config = getDefaultConfig(__dirname); + +module.exports = withNativeWind(config, { input: "./global.css" }); diff --git a/ts/mobile-starter/native/package.json b/ts/mobile-starter/native/package.json new file mode 100644 index 00000000..652bb959 --- /dev/null +++ b/ts/mobile-starter/native/package.json @@ -0,0 +1,36 @@ +{ + "name": "native", + "version": "1.0.0", + "type": "module", + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "dev": "expo start --clear", + "android": "expo run:android", + "ios": "expo run:ios", + "prebuild": "expo prebuild", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@expo/vector-icons": "^15.0.0", + "expo": "^54.0.0", + "expo-linking": "~8.0.0", + "expo-router": "~6.0.0", + "expo-secure-store": "~15.0.0", + "expo-status-bar": "~3.0.0", + "expo-web-browser": "~15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-native": "^0.81.0", + "react-native-safe-area-context": "^5.0.0", + "react-native-screens": "~4.0.0", + "nativewind": "^4.1.0", + "tailwindcss": "~4.1.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/react": "~19.0.0", + "typescript": "^5.6.0" + }, + "private": true +} diff --git a/ts/mobile-starter/native/tsconfig.json b/ts/mobile-starter/native/tsconfig.json new file mode 100644 index 00000000..228af569 --- /dev/null +++ b/ts/mobile-starter/native/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] +} diff --git a/ts/mobile-starter/package.json b/ts/mobile-starter/package.json new file mode 100644 index 00000000..bd1f0774 --- /dev/null +++ b/ts/mobile-starter/package.json @@ -0,0 +1,18 @@ +{ + "name": "mobile-starter", + "private": true, + "version": "0.0.1", + "license": "MPL-2.0", + "type": "module", + "workspaces": ["backend", "web", "native"], + "scripts": { + "check-types": "turbo check-types", + "lint": "biome check .", + "lint:fix": "biome check --write ." + }, + "devDependencies": { + "@biomejs/biome": "^1.9.0", + "turbo": "^2.3.0", + "typescript": "^5.6.0" + } +} diff --git a/ts/mobile-starter/tsconfig.json b/ts/mobile-starter/tsconfig.json new file mode 100644 index 00000000..7c240e76 --- /dev/null +++ b/ts/mobile-starter/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "composite": true, + "paths": { + "~encore/*": ["./encore.gen/*"] + } + } +} diff --git a/ts/mobile-starter/turbo.json b/ts/mobile-starter/turbo.json new file mode 100644 index 00000000..c372ebb5 --- /dev/null +++ b/ts/mobile-starter/turbo.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "dev": { + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "check-types": {} + } +} diff --git a/ts/mobile-starter/web/index.html b/ts/mobile-starter/web/index.html new file mode 100644 index 00000000..5a705932 --- /dev/null +++ b/ts/mobile-starter/web/index.html @@ -0,0 +1,12 @@ + + + + + + Mobile Starter + + +
+ + + diff --git a/ts/mobile-starter/web/package.json b/ts/mobile-starter/web/package.json new file mode 100644 index 00000000..f0c749cd --- /dev/null +++ b/ts/mobile-starter/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --port=3001", + "build": "vite build", + "serve": "vite preview", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@hookform/resolvers": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/react-query": "^5.60.0", + "@tanstack/react-router": "^1.100.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.470.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.0", + "tailwind-merge": "^3.0.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@tanstack/router-plugin": "^1.100.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/ts/mobile-starter/web/src/components/ui/button.tsx b/ts/mobile-starter/web/src/components/ui/button.tsx new file mode 100644 index 00000000..98e39ebe --- /dev/null +++ b/ts/mobile-starter/web/src/components/ui/button.tsx @@ -0,0 +1,49 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: + "rounded-lg bg-primary text-primary-foreground hover:bg-primary-hover", + outline: + "rounded-lg border border-border bg-transparent text-text hover:bg-surface-muted", + destructive: + "rounded-lg bg-error text-text-invert hover:bg-error/90", + ghost: + "rounded-lg bg-transparent text-text hover:bg-surface-muted", + }, + size: { + default: "px-6 py-3 text-sm", + sm: "px-4 py-2 text-sm", + lg: "px-8 py-4 text-base", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "primary", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export function Button({ + className, + variant, + size, + ...props +}: ButtonProps) { + return ( + + + {errors.password && ( +

+ {errors.password.message} +

+ )} + + + + + + +

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

+ + ); +} diff --git a/ts/mobile-starter/web/src/features/auth/login-form/login-form.types.ts b/ts/mobile-starter/web/src/features/auth/login-form/login-form.types.ts new file mode 100644 index 00000000..c2e8a212 --- /dev/null +++ b/ts/mobile-starter/web/src/features/auth/login-form/login-form.types.ts @@ -0,0 +1,8 @@ +import type { z } from "zod"; +import type { loginFormSchema } from "./login-form.schema"; + +export type LoginFormData = z.infer; + +export interface LoginFormProps { + onSuccess?: () => void; +} diff --git a/ts/mobile-starter/web/src/features/auth/oauth-buttons/oauth-buttons.tsx b/ts/mobile-starter/web/src/features/auth/oauth-buttons/oauth-buttons.tsx new file mode 100644 index 00000000..10740bfd --- /dev/null +++ b/ts/mobile-starter/web/src/features/auth/oauth-buttons/oauth-buttons.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { apiClient } from "@/lib/api/client"; +import type { auth } from "@/lib/api/client.gen"; + +function GoogleIcon() { + return ( + + ); +} + +function MicrosoftIcon() { + return ( + + ); +} + +interface OAuthButtonsProps { + label?: "sign_in" | "sign_up"; +} + +export function OAuthButtons({ label = "sign_in" }: OAuthButtonsProps) { + const [loadingProvider, setLoadingProvider] = + useState(null); + + const handleOAuth = async (provider: auth.OAuthProvider) => { + setLoadingProvider(provider); + try { + const redirectUri = `${window.location.origin}/auth/oauth/callback`; + const result = await apiClient.auth.getOAuthUrl({ + provider, + redirectUri, + }); + window.location.href = result.url; + } catch { + setLoadingProvider(null); + } + }; + + const verb = label === "sign_up" ? "Sign up" : "Continue"; + + return ( +
+ + + + +
+
+
+
+
+ + or continue with email + +
+
+
+ ); +} diff --git a/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.hooks.ts b/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.hooks.ts new file mode 100644 index 00000000..13a1282e --- /dev/null +++ b/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.hooks.ts @@ -0,0 +1,139 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useAuth } from "@/features/auth/lib/auth-provider"; +import { apiClient } from "@/lib/api/client"; +import { signUpFormSchema } from "./signup-form.schema"; +import type { + SignUpFormData, + SignUpFormProps, + SignUpStep, +} from "./signup-form.types"; + +export function useSignUpForm(props?: SignUpFormProps) { + const { onSuccess } = props ?? {}; + const { signUp, storeSession } = useAuth(); + + const [step, setStep] = useState("form"); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [generalError, setGeneralError] = useState(""); + const [verifyCode, setVerifyCode] = useState(""); + const [emailAddress, setEmailAddress] = useState(""); + const [pendingToken, setPendingToken] = useState(""); + + const form = useForm({ + resolver: zodResolver(signUpFormSchema), + defaultValues: { + firstName: "", + lastName: "", + email: "", + password: "", + confirmPassword: "", + }, + }); + + const togglePassword = useCallback(() => { + setShowPassword((prev) => !prev); + }, []); + + const toggleConfirmPassword = useCallback(() => { + setShowConfirmPassword((prev) => !prev); + }, []); + + const handleSubmit = useCallback( + async (data: SignUpFormData) => { + setIsLoading(true); + setGeneralError(""); + + try { + const result = await signUp( + data.email, + data.password, + data.firstName, + data.lastName, + ); + + if (result.status === "verify_email") { + setEmailAddress(data.email); + setPendingToken(result.pendingAuthenticationToken ?? ""); + setStep("verify"); + } else if (result.status === "complete") { + onSuccess?.(); + } + } catch (err) { + setGeneralError( + err instanceof Error + ? err.message + : "Something went wrong. Please try again.", + ); + } finally { + setIsLoading(false); + } + }, + [signUp, onSuccess], + ); + + const handleVerify = useCallback(async () => { + setIsLoading(true); + setGeneralError(""); + + try { + const result = await apiClient.auth.verifyEmail({ + code: verifyCode, + pendingAuthenticationToken: pendingToken, + }); + + storeSession(result); + onSuccess?.(); + } catch (err) { + setGeneralError( + err instanceof Error + ? err.message + : "Invalid verification code. Please try again.", + ); + } finally { + setIsLoading(false); + } + }, [verifyCode, pendingToken, storeSession, onSuccess]); + + const handleResendCode = useCallback(async () => { + const data = form.getValues(); + setIsLoading(true); + setGeneralError(""); + + try { + const result = await signUp( + data.email, + data.password, + data.firstName, + data.lastName, + ); + if (result.pendingAuthenticationToken) { + setPendingToken(result.pendingAuthenticationToken); + } + } catch { + setGeneralError("Failed to resend code. Please try again."); + } finally { + setIsLoading(false); + } + }, [form, signUp]); + + return { + form, + step, + showPassword, + togglePassword, + showConfirmPassword, + toggleConfirmPassword, + isLoading, + generalError, + emailAddress, + verifyCode, + setVerifyCode, + handleSubmit: form.handleSubmit(handleSubmit), + handleVerify, + handleResendCode, + }; +} diff --git a/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.schema.ts b/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.schema.ts new file mode 100644 index 00000000..b7cf6bbc --- /dev/null +++ b/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const signUpFormSchema = z + .object({ + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + email: z + .string() + .min(1, "Email address is required") + .email("Please enter a valid email address"), + password: z + .string() + .min(1, "Password is required") + .min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); diff --git a/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.tsx b/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.tsx new file mode 100644 index 00000000..cf93dd57 --- /dev/null +++ b/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.tsx @@ -0,0 +1,226 @@ +import { Link } from "@tanstack/react-router"; +import { Eye, EyeOff } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { OAuthButtons } from "../oauth-buttons/oauth-buttons"; +import { useSignUpForm } from "./signup-form.hooks"; +import type { SignUpFormProps } from "./signup-form.types"; + +export function SignUpForm(props: SignUpFormProps) { + const { + form, + step, + showPassword, + togglePassword, + showConfirmPassword, + toggleConfirmPassword, + isLoading, + generalError, + emailAddress, + verifyCode, + setVerifyCode, + handleSubmit, + handleVerify, + handleResendCode, + } = useSignUpForm(props); + + const { + register, + formState: { errors }, + } = form; + + if (step === "verify") { + return ( +
+
+

Verify your email

+

+ We sent a verification code to {emailAddress} +

+
+ + + {generalError && ( +
+ {generalError} +
+ )} + +
+
+ + setVerifyCode(e.target.value)} + className="mt-1" + /> +
+ + + + +
+
+
+ ); + } + + return ( +
+
+

Create your account

+

+ Get started with your new account +

+
+ + + + + {generalError && ( +
+ {generalError} +
+ )} + +
+
+
+ + + {errors.firstName && ( +

+ {errors.firstName.message} +

+ )} +
+
+ + + {errors.lastName && ( +

+ {errors.lastName.message} +

+ )} +
+
+ +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+ + +
+ {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+ +
+ + +
+ {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + +
+
+ +

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

+
+ ); +} diff --git a/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.types.ts b/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.types.ts new file mode 100644 index 00000000..22f54495 --- /dev/null +++ b/ts/mobile-starter/web/src/features/auth/signup-form/signup-form.types.ts @@ -0,0 +1,10 @@ +import type { z } from "zod"; +import type { signUpFormSchema } from "./signup-form.schema"; + +export type SignUpFormData = z.infer; + +export type SignUpStep = "form" | "verify"; + +export interface SignUpFormProps { + onSuccess?: () => void; +} diff --git a/ts/mobile-starter/web/src/features/invitations/invitation-list/invitation-list.tsx b/ts/mobile-starter/web/src/features/invitations/invitation-list/invitation-list.tsx new file mode 100644 index 00000000..b4363c6b --- /dev/null +++ b/ts/mobile-starter/web/src/features/invitations/invitation-list/invitation-list.tsx @@ -0,0 +1,66 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api/client"; +import { usePermissions } from "@/features/auth/lib/use-permissions"; +import { P } from "@/lib/permissions"; +import { Button } from "@/components/ui/button"; + +export function InvitationList() { + const { can } = usePermissions(); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ["invitations"], + queryFn: () => apiClient.auth.listInvitations(), + }); + + const handleRevoke = async (id: string) => { + try { + await apiClient.auth.revokeInvitation({ id }); + refetch(); + } catch { + // Error handled silently + } + }; + + if (isLoading) { + return

Loading invitations...

; + } + + const invitations = data?.invitations ?? []; + + if (invitations.length === 0) { + return ( +

No pending invitations.

+ ); + } + + return ( +
+

Pending Invitations

+
+ {invitations.map((inv) => ( +
+
+

{inv.email}

+

+ Status: {inv.status} +

+
+ {can(P.MEMBERS_REMOVE) && ( + + )} +
+ ))} +
+
+ ); +} diff --git a/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.hooks.ts b/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.hooks.ts new file mode 100644 index 00000000..eda0b984 --- /dev/null +++ b/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.hooks.ts @@ -0,0 +1,51 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback, useState } from "react"; +import { useForm } from "react-hook-form"; +import { apiClient } from "@/lib/api/client"; +import { inviteFormSchema, type InviteFormData } from "./invite-form.schema"; + +export function useInviteForm(onSuccess?: () => void) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const form = useForm({ + resolver: zodResolver(inviteFormSchema), + defaultValues: { email: "", role: "member" }, + }); + + const handleSubmit = useCallback( + async (data: InviteFormData) => { + setIsLoading(true); + setError(""); + setSuccess(""); + + try { + await apiClient.auth.sendInvitation({ + email: data.email, + role: data.role, + }); + setSuccess(`Invitation sent to ${data.email}`); + form.reset(); + onSuccess?.(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Failed to send invitation.", + ); + } finally { + setIsLoading(false); + } + }, + [form, onSuccess], + ); + + return { + form, + isLoading, + error, + success, + handleSubmit: form.handleSubmit(handleSubmit), + }; +} diff --git a/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.schema.ts b/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.schema.ts new file mode 100644 index 00000000..a8c19cae --- /dev/null +++ b/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const inviteFormSchema = z.object({ + email: z + .string() + .min(1, "Email address is required") + .email("Please enter a valid email address"), + role: z.enum(["admin", "member"]), +}); + +export type InviteFormData = z.infer; diff --git a/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.tsx b/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.tsx new file mode 100644 index 00000000..e8f9016b --- /dev/null +++ b/ts/mobile-starter/web/src/features/invitations/invite-form/invite-form.tsx @@ -0,0 +1,70 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useInviteForm } from "./invite-form.hooks"; + +interface InviteFormProps { + onSuccess?: () => void; +} + +export function InviteForm({ onSuccess }: InviteFormProps) { + const { form, isLoading, error, success, handleSubmit } = + useInviteForm(onSuccess); + + const { + register, + formState: { errors }, + } = form; + + return ( +
+

Invite a member

+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + +
+ +
+ +
+
+
+ ); +} diff --git a/ts/mobile-starter/web/src/index.css b/ts/mobile-starter/web/src/index.css new file mode 100644 index 00000000..a95c0d9e --- /dev/null +++ b/ts/mobile-starter/web/src/index.css @@ -0,0 +1,36 @@ +@import "tailwindcss"; + +@theme { + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-primary-foreground: #ffffff; + + --color-surface: #f9fafb; + --color-surface-elevated: #ffffff; + --color-surface-muted: #f3f4f6; + + --color-text: #111827; + --color-text-secondary: #6b7280; + --color-text-muted: #9ca3af; + --color-text-invert: #ffffff; + + --color-border: #e5e7eb; + --color-border-muted: #f3f4f6; + + --color-ring: #2563eb; + + --color-success: #10b981; + --color-success-muted: #d1fae5; + --color-error: #ef4444; + --color-error-muted: #fee2e2; + --color-warning: #f59e0b; + --color-warning-muted: #fef3c7; + + --font-sans: "Inter", system-ui, sans-serif; +} + +body { + font-family: var(--font-sans); + background-color: var(--color-surface); + color: var(--color-text); +} diff --git a/ts/mobile-starter/web/src/lib/api/client.gen.ts b/ts/mobile-starter/web/src/lib/api/client.gen.ts new file mode 100644 index 00000000..84527ed1 --- /dev/null +++ b/ts/mobile-starter/web/src/lib/api/client.gen.ts @@ -0,0 +1,142 @@ +/** + * Placeholder API client. + * + * This file will be regenerated by Encore after running: + * task gen:api:web + * + * For now, this provides type stubs so the app compiles. + */ + +export const Local = "http://localhost:4000"; + +// biome-ignore lint/suspicious/noExplicitAny: placeholder types +type Any = any; + +export namespace auth { + export type OAuthProvider = "GoogleOAuth" | "MicrosoftOAuth"; + + export interface UserInfo { + id: string; + email: string; + firstName: string | null; + lastName: string | null; + profilePictureUrl: string | null; + } + + export interface SignInResponse { + status: "complete" | "mfa_required" | "verify_email"; + accessToken?: string; + refreshToken?: string; + user?: UserInfo; + pendingAuthenticationToken?: string; + organizationName?: string; + } + + export interface SignUpResponse { + status: "complete" | "verify_email"; + accessToken?: string; + refreshToken?: string; + user?: UserInfo; + userId?: string; + pendingAuthenticationToken?: string; + organizationName?: string; + } + + export interface RefreshResponse { + accessToken: string; + refreshToken: string; + user: UserInfo; + organizationName?: string; + } + + export interface SessionResponse { + userID: string; + email: string; + role: "admin" | "member"; + organizationId: string; + } + + export interface InvitationItem { + id: string; + email: string; + status: string; + createdAt: string; + expiresAt: string; + } +} + +interface AuthOptions { + auth?: () => { authorization: string } | undefined; +} + +export default class Client { + private baseUrl: string; + private opts: AuthOptions; + + constructor(baseUrl: string, opts: AuthOptions = {}) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.opts = opts; + } + + private async fetch(path: string, init?: RequestInit): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...(init?.headers as Record), + }; + const authResult = this.opts.auth?.(); + if (authResult) { + headers.Authorization = authResult.authorization; + } + const resp = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers, + }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + throw new Error(body.message || `Request failed: ${resp.status}`); + } + const text = await resp.text(); + return text ? JSON.parse(text) : {}; + } + + auth = { + signIn: (req: { email: string; password: string }): Promise => + this.fetch("/api/auth/sign-in", { method: "POST", body: JSON.stringify(req) }), + + signUp: (req: { email: string; password: string; firstName: string; lastName: string }): Promise => + this.fetch("/api/auth/sign-up", { method: "POST", body: JSON.stringify(req) }), + + signOut: (req: { sessionId?: string }): Promise<{ success: true }> => + this.fetch("/api/auth/sign-out", { method: "POST", body: JSON.stringify(req) }), + + refresh: (req: { refreshToken: string }): Promise => + this.fetch("/api/auth/refresh", { method: "POST", body: JSON.stringify(req) }), + + getSession: (): Promise => + this.fetch("/api/auth/session"), + + verifyEmail: (req: { code: string; pendingAuthenticationToken: string }): Promise => + this.fetch("/api/auth/verify-email", { method: "POST", body: JSON.stringify(req) }), + + forgotPassword: (req: { email: string; passwordResetUrl: string }): Promise<{ success: true }> => + this.fetch("/api/auth/forgot-password", { method: "POST", body: JSON.stringify(req) }), + + resetPassword: (req: { token: string; newPassword: string }): Promise<{ success: true }> => + this.fetch("/api/auth/reset-password", { method: "POST", body: JSON.stringify(req) }), + + getOAuthUrl: (req: { provider: auth.OAuthProvider; redirectUri: string }): Promise<{ url: string }> => + this.fetch(`/api/auth/oauth/url?provider=${req.provider}&redirectUri=${encodeURIComponent(req.redirectUri)}`), + + oauthCallback: (req: { code: string }): Promise => + this.fetch("/api/auth/oauth/callback", { method: "POST", body: JSON.stringify(req) }), + + sendInvitation: (req: { email: string; role: string }): Promise<{ invitationId: string; email: string; status: string }> => + this.fetch("/api/auth/invitations", { method: "POST", body: JSON.stringify(req) }), + + listInvitations: (): Promise<{ invitations: auth.InvitationItem[] }> => + this.fetch("/api/auth/invitations"), + + revokeInvitation: (req: { id: string }): Promise => + this.fetch(`/api/auth/invitations/${req.id}`, { method: "DELETE" }), + }; +} diff --git a/ts/mobile-starter/web/src/lib/api/client.ts b/ts/mobile-starter/web/src/lib/api/client.ts new file mode 100644 index 00000000..61deff96 --- /dev/null +++ b/ts/mobile-starter/web/src/lib/api/client.ts @@ -0,0 +1,16 @@ +import Client, { Local } from "./client.gen"; + +const baseUrl = import.meta.env.VITE_API_URL || Local; + +let accessToken: string | null = null; + +export function setAccessToken(token: string | null) { + accessToken = token; +} + +export const apiClient = new Client(baseUrl, { + auth: () => { + if (accessToken) return { authorization: `Bearer ${accessToken}` }; + return undefined; + }, +}); diff --git a/ts/mobile-starter/web/src/lib/permissions.ts b/ts/mobile-starter/web/src/lib/permissions.ts new file mode 100644 index 00000000..57984b32 --- /dev/null +++ b/ts/mobile-starter/web/src/lib/permissions.ts @@ -0,0 +1,40 @@ +export const P = { + DASHBOARD_VIEW: "dashboard:view", + PROFILE_VIEW: "profile:view", + PROFILE_EDIT: "profile:edit", + MEMBERS_VIEW: "members:view", + MEMBERS_INVITE: "members:invite", + MEMBERS_REMOVE: "members:remove", +} as const; + +export type Permission = (typeof P)[keyof typeof P]; + +export type RoleType = "admin" | "member"; + +export const ROLE_PERMISSIONS: Record> = { + admin: new Set([ + P.DASHBOARD_VIEW, + P.PROFILE_VIEW, + P.PROFILE_EDIT, + P.MEMBERS_VIEW, + P.MEMBERS_INVITE, + P.MEMBERS_REMOVE, + ]), + member: new Set([ + P.DASHBOARD_VIEW, + P.PROFILE_VIEW, + P.PROFILE_EDIT, + P.MEMBERS_VIEW, + ]), +}; + +export function hasPermission(role: RoleType, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.has(permission) ?? false; +} + +const VALID_ROLES = new Set(["admin", "member"]); + +export function mapRole(slug?: string): RoleType { + if (!slug) return "member"; + return VALID_ROLES.has(slug) ? (slug as RoleType) : "member"; +} diff --git a/ts/mobile-starter/web/src/lib/query-client.ts b/ts/mobile-starter/web/src/lib/query-client.ts new file mode 100644 index 00000000..162409c4 --- /dev/null +++ b/ts/mobile-starter/web/src/lib/query-client.ts @@ -0,0 +1,10 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + retry: 1, + }, + }, +}); diff --git a/ts/mobile-starter/web/src/lib/utils.ts b/ts/mobile-starter/web/src/lib/utils.ts new file mode 100644 index 00000000..ac680b30 --- /dev/null +++ b/ts/mobile-starter/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/ts/mobile-starter/web/src/main.tsx b/ts/mobile-starter/web/src/main.tsx new file mode 100644 index 00000000..7e63c228 --- /dev/null +++ b/ts/mobile-starter/web/src/main.tsx @@ -0,0 +1,40 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import ReactDOM from "react-dom/client"; +import { AuthProvider, useAuth } from "@/features/auth/lib/auth-provider"; +import { queryClient } from "@/lib/query-client"; +import { routeTree } from "./routeTree.gen"; + +const router = createRouter({ + routeTree, + defaultPreload: "intent", + context: { auth: {} as ReturnType }, +}); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +function InnerApp() { + const auth = useAuth(); + return ; +} + +const rootElement = document.getElementById("app"); + +if (!rootElement) { + throw new Error("Root element not found"); +} + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render( + + + + + , + ); +} diff --git a/ts/mobile-starter/web/src/routes/__root.tsx b/ts/mobile-starter/web/src/routes/__root.tsx new file mode 100644 index 00000000..9cab0efd --- /dev/null +++ b/ts/mobile-starter/web/src/routes/__root.tsx @@ -0,0 +1,13 @@ +import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; +import type { useAuth } from "@/features/auth/lib/auth-provider"; +import "../index.css"; + +export type RouterAppContext = { auth: ReturnType }; + +export const Route = createRootRouteWithContext()({ + component: RootComponent, +}); + +function RootComponent() { + return ; +} diff --git a/ts/mobile-starter/web/src/routes/_app.tsx b/ts/mobile-starter/web/src/routes/_app.tsx new file mode 100644 index 00000000..2f2adc01 --- /dev/null +++ b/ts/mobile-starter/web/src/routes/_app.tsx @@ -0,0 +1,69 @@ +import { createFileRoute, Link, Outlet, redirect } from "@tanstack/react-router"; +import { LayoutDashboard, Users, User, LogOut } from "lucide-react"; +import { useAuth } from "@/features/auth/lib/auth-provider"; +import { useAppSession } from "@/features/auth/lib/auth-session"; + +export const Route = createFileRoute("/_app")({ + beforeLoad: ({ context }) => { + if (!context.auth.isLoading && !context.auth.user) { + throw redirect({ to: "/login" }); + } + }, + component: AppLayout, +}); + +function AppLayout() { + const { signOut } = useAuth(); + const { session } = useAppSession(); + + return ( +
+
+
+ + +
+ {session && ( + + {session.name} ({session.role}) + + )} + +
+
+
+ +
+ +
+
+ ); +} diff --git a/ts/mobile-starter/web/src/routes/_app/dashboard.tsx b/ts/mobile-starter/web/src/routes/_app/dashboard.tsx new file mode 100644 index 00000000..2987e6ba --- /dev/null +++ b/ts/mobile-starter/web/src/routes/_app/dashboard.tsx @@ -0,0 +1,50 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Card, CardTitle, CardDescription } from "@/components/ui/card"; +import { useAppSession } from "@/features/auth/lib/auth-session"; +import { P } from "@/lib/permissions"; +import { requireRoutePermission } from "@/features/auth/lib/route-guard"; + +export const Route = createFileRoute("/_app/dashboard")({ + beforeLoad: ({ context }) => { + requireRoutePermission(context, P.DASHBOARD_VIEW); + }, + component: Dashboard, +}); + +function Dashboard() { + const { session } = useAppSession(); + + if (!session) return null; + + return ( +
+
+

Dashboard

+

+ Welcome back, {session.name} +

+
+ +
+ + Role + + + {session.role} + + + + + + Organization + {session.organizationName} + + + + Email + {session.email} + +
+
+ ); +} diff --git a/ts/mobile-starter/web/src/routes/_app/members.tsx b/ts/mobile-starter/web/src/routes/_app/members.tsx new file mode 100644 index 00000000..d849bddf --- /dev/null +++ b/ts/mobile-starter/web/src/routes/_app/members.tsx @@ -0,0 +1,40 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { P } from "@/lib/permissions"; +import { requireRoutePermission } from "@/features/auth/lib/route-guard"; +import { usePermissions } from "@/features/auth/lib/use-permissions"; +import { InviteForm } from "@/features/invitations/invite-form/invite-form"; +import { InvitationList } from "@/features/invitations/invitation-list/invitation-list"; +import { useQueryClient } from "@tanstack/react-query"; + +export const Route = createFileRoute("/_app/members")({ + beforeLoad: ({ context }) => { + requireRoutePermission(context, P.MEMBERS_VIEW); + }, + component: Members, +}); + +function Members() { + const { can } = usePermissions(); + const queryClient = useQueryClient(); + + const handleInviteSuccess = () => { + queryClient.invalidateQueries({ queryKey: ["invitations"] }); + }; + + return ( +
+
+

Members

+

+ Manage your organization members +

+
+ + {can(P.MEMBERS_INVITE) && ( + + )} + + +
+ ); +} diff --git a/ts/mobile-starter/web/src/routes/_app/profile.tsx b/ts/mobile-starter/web/src/routes/_app/profile.tsx new file mode 100644 index 00000000..138a0df9 --- /dev/null +++ b/ts/mobile-starter/web/src/routes/_app/profile.tsx @@ -0,0 +1,58 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Card, CardTitle, CardDescription } from "@/components/ui/card"; +import { useAppSession } from "@/features/auth/lib/auth-session"; +import { P } from "@/lib/permissions"; +import { requireRoutePermission } from "@/features/auth/lib/route-guard"; + +export const Route = createFileRoute("/_app/profile")({ + beforeLoad: ({ context }) => { + requireRoutePermission(context, P.PROFILE_VIEW); + }, + component: Profile, +}); + +function Profile() { + const { session } = useAppSession(); + + if (!session) return null; + + return ( +
+
+

Profile

+

Your account details

+
+ + +
+
+ Name + {session.name} +
+
+ Email + {session.email} +
+
+ Role + + + {session.role} + + +
+
+ Organization + {session.organizationName} +
+
+ User ID + + {session.userId} + +
+
+
+
+ ); +} diff --git a/ts/mobile-starter/web/src/routes/auth/oauth/callback.tsx b/ts/mobile-starter/web/src/routes/auth/oauth/callback.tsx new file mode 100644 index 00000000..d610e05e --- /dev/null +++ b/ts/mobile-starter/web/src/routes/auth/oauth/callback.tsx @@ -0,0 +1,56 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/features/auth/lib/auth-provider"; +import { apiClient } from "@/lib/api/client"; + +export const Route = createFileRoute("/auth/oauth/callback")({ + validateSearch: (search: Record) => ({ + code: (search.code as string) ?? "", + }), + component: OAuthCallback, +}); + +function OAuthCallback() { + const { code } = Route.useSearch(); + const { storeSession } = useAuth(); + const navigate = useNavigate(); + const [error, setError] = useState(""); + + useEffect(() => { + if (!code) { + setError("No authorization code received"); + return; + } + + apiClient.auth + .oauthCallback({ code }) + .then((result) => { + storeSession(result); + navigate({ to: "/_app/dashboard" }); + }) + .catch((err) => { + setError( + err instanceof Error ? err.message : "OAuth authentication failed", + ); + }); + }, [code, storeSession, navigate]); + + if (error) { + return ( +
+
+

{error}

+ + Back to login + +
+
+ ); + } + + return ( +
+

Completing sign in...

+
+ ); +} diff --git a/ts/mobile-starter/web/src/routes/forgot-password.tsx b/ts/mobile-starter/web/src/routes/forgot-password.tsx new file mode 100644 index 00000000..2e96a413 --- /dev/null +++ b/ts/mobile-starter/web/src/routes/forgot-password.tsx @@ -0,0 +1,86 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { AuthLayout } from "@/features/auth/auth-layout/auth-layout"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { apiClient } from "@/lib/api/client"; + +export const Route = createFileRoute("/forgot-password")({ + component: ForgotPassword, +}); + +function ForgotPassword() { + const [email, setEmail] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [sent, setSent] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + await apiClient.auth.forgotPassword({ + email, + passwordResetUrl: `${window.location.origin}/reset-password`, + }); + setSent(true); + } finally { + setIsLoading(false); + } + }; + + return ( + +
+

Reset your password

+

+ Enter your email and we'll send you a reset link +

+
+ + + {sent ? ( +
+

+ If an account with that email exists, we sent a password reset + link. Check your inbox. +

+ + Back to login + +
+ ) : ( +
+
+ + setEmail(e.target.value)} + required + className="mt-1" + /> +
+ +
+ + Back to login + +
+
+ )} +
+
+ ); +} diff --git a/ts/mobile-starter/web/src/routes/index.tsx b/ts/mobile-starter/web/src/routes/index.tsx new file mode 100644 index 00000000..7d8a764e --- /dev/null +++ b/ts/mobile-starter/web/src/routes/index.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/")({ + beforeLoad: ({ context }) => { + if (!context.auth.isLoading && context.auth.user) { + throw redirect({ to: "/_app/dashboard" }); + } + throw redirect({ to: "/login" }); + }, +}); diff --git a/ts/mobile-starter/web/src/routes/login.tsx b/ts/mobile-starter/web/src/routes/login.tsx new file mode 100644 index 00000000..03006715 --- /dev/null +++ b/ts/mobile-starter/web/src/routes/login.tsx @@ -0,0 +1,22 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { AuthLayout } from "@/features/auth/auth-layout/auth-layout"; +import { LoginForm } from "@/features/auth/login-form/login-form"; + +export const Route = createFileRoute("/login")({ + beforeLoad: ({ context }) => { + if (!context.auth.isLoading && context.auth.user) { + throw redirect({ to: "/_app/dashboard" }); + } + }, + component: Login, +}); + +function Login() { + const navigate = useNavigate(); + + return ( + + navigate({ to: "/_app/dashboard" })} /> + + ); +} diff --git a/ts/mobile-starter/web/src/routes/reset-password.tsx b/ts/mobile-starter/web/src/routes/reset-password.tsx new file mode 100644 index 00000000..26ad99d5 --- /dev/null +++ b/ts/mobile-starter/web/src/routes/reset-password.tsx @@ -0,0 +1,112 @@ +import { createFileRoute, Link, useSearch } from "@tanstack/react-router"; +import { useState } from "react"; +import { AuthLayout } from "@/features/auth/auth-layout/auth-layout"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { apiClient } from "@/lib/api/client"; + +export const Route = createFileRoute("/reset-password")({ + validateSearch: (search: Record) => ({ + token: (search.token as string) ?? "", + }), + component: ResetPassword, +}); + +function ResetPassword() { + const { token } = useSearch({ from: "/reset-password" }); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setIsLoading(true); + setError(""); + + try { + await apiClient.auth.resetPassword({ token, newPassword }); + setSuccess(true); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to reset password", + ); + } finally { + setIsLoading(false); + } + }; + + return ( + +
+

Set new password

+

+ Enter your new password below +

+
+ + + {success ? ( +
+

+ Your password has been reset successfully. +

+ + Sign in with your new password + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} +
+ + setNewPassword(e.target.value)} + required + className="mt-1" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + className="mt-1" + /> +
+ +
+ )} +
+
+ ); +} diff --git a/ts/mobile-starter/web/src/routes/signup.tsx b/ts/mobile-starter/web/src/routes/signup.tsx new file mode 100644 index 00000000..6e15e6d7 --- /dev/null +++ b/ts/mobile-starter/web/src/routes/signup.tsx @@ -0,0 +1,22 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { AuthLayout } from "@/features/auth/auth-layout/auth-layout"; +import { SignUpForm } from "@/features/auth/signup-form/signup-form"; + +export const Route = createFileRoute("/signup")({ + beforeLoad: ({ context }) => { + if (!context.auth.isLoading && context.auth.user) { + throw redirect({ to: "/_app/dashboard" }); + } + }, + component: SignUp, +}); + +function SignUp() { + const navigate = useNavigate(); + + return ( + + navigate({ to: "/_app/dashboard" })} /> + + ); +} diff --git a/ts/mobile-starter/web/tsconfig.json b/ts/mobile-starter/web/tsconfig.json new file mode 100644 index 00000000..71c99a4a --- /dev/null +++ b/ts/mobile-starter/web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["vite/client"], + "rootDirs": ["."], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/ts/mobile-starter/web/vite.config.ts b/ts/mobile-starter/web/vite.config.ts new file mode 100644 index 00000000..71c38e26 --- /dev/null +++ b/ts/mobile-starter/web/vite.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import tailwindcss from "@tailwindcss/vite"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [tailwindcss(), tanstackRouter({}), react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +});