Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ts/mobile-starter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
.encore
encore.gen
.turbo
.expo
154 changes: 154 additions & 0 deletions ts/mobile-starter/README.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 43 additions & 0 deletions ts/mobile-starter/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions ts/mobile-starter/backend/auth/auth.helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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}`);
}
}
56 changes: 56 additions & 0 deletions ts/mobile-starter/backend/auth/auth.ts
Original file line number Diff line number Diff line change
@@ -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<AuthParams, AuthData>(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,
});
3 changes: 3 additions & 0 deletions ts/mobile-starter/backend/auth/encore.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Service } from "encore.dev/service";

export default new Service("auth");
Loading