├── .husky ├── commit-msg └── pre-commit ├── .vscode └── settings.json ├── env.d.ts ├── postcss.config.mjs ├── public ├── avatar.png ├── magic-icon.png ├── favicons │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── site.webmanifest ├── french-house-stack.png └── locales │ ├── en │ ├── sidebar.json │ ├── settings.json │ ├── drag-and-drop.json │ ├── organization-settings.json │ ├── common.json │ ├── organizations.json │ ├── landing.json │ ├── header.json │ ├── pagination.json │ ├── user-profile.json │ ├── settings-user-profile.json │ ├── organizations-new.json │ ├── onboarding-user-profile.json │ ├── onboarding-organization.json │ ├── accept-membership-invite.json │ ├── settings-account.json │ ├── organization-profile.json │ ├── organization-team-members.json │ ├── login.json │ └── register.json │ └── de │ └── common.json ├── .eslintignore ├── app ├── utils │ ├── types.ts │ ├── shadcn-ui.ts │ ├── async-for-each.server.test.ts │ ├── throw-if-entity-is-missing.server.ts │ ├── async-pipe.test.ts │ ├── get-root-directory.ts │ ├── async-for-each.server.ts │ ├── get-search-parameter-from-request.server.ts │ ├── throw-if-entity-is-missing.server.test.ts │ ├── trace.ts │ ├── combine-headers.server.ts │ ├── to-form-data.ts │ ├── get-error-message.ts │ ├── pagination.server.ts │ ├── get-error-message.test.ts │ ├── combine-headers.server.test.ts │ ├── to-form-data.test.ts │ ├── get-search-parameter-from-request.server.test.ts │ └── async-pipe.ts ├── test │ ├── mocks │ │ ├── handlers │ │ │ ├── magic.ts │ │ │ └── clerk.ts │ │ ├── browser.ts │ │ ├── msw-utils.server.ts │ │ ├── msw-utils.ts │ │ └── server.ts │ ├── setup-test-environment.ts │ ├── generate-random-did.server.ts │ ├── react-test-utils.tsx │ └── i18n.ts ├── routes │ ├── settings._index.tsx │ ├── onboarding._index.tsx │ ├── logout.ts │ ├── organizations.ts │ ├── organizations_.$organizationSlug.home.tsx │ ├── organizations_.invite.tsx │ ├── verify-complete.tsx │ ├── organizations_.$organizationSlug.tsx │ ├── organizations_.$organizationSlug.settings.tsx │ ├── settings.tsx │ └── organizations_.$organizationSlug.settings.team-members.tsx ├── features │ ├── organizations │ │ ├── organizations-constants.ts │ │ ├── organizations-client-schemas.ts │ │ ├── accept-membership-invite-page-component.test.tsx │ │ ├── organizations-switcher-component.test.tsx │ │ ├── invite-link-uses-model.server.ts │ │ ├── organizations-sidebar-component.tsx │ │ └── accept-membership-invite-page-component.tsx │ ├── localization │ │ ├── i18n.ts │ │ ├── get-page-title.server.ts │ │ ├── i18next.server.ts │ │ ├── use-translation.test.tsx │ │ ├── use-translation.ts │ │ └── get-page-title.server.test.ts │ ├── settings │ │ ├── settings-client-schemas.ts │ │ ├── settings-helpers.server.ts │ │ ├── settings-loaders.server.ts │ │ ├── settings-actions.server.ts │ │ └── settings-helpers.server.test.ts │ ├── onboarding │ │ ├── onboarding-client-schemas.ts │ │ ├── onboarding-loaders.server.ts │ │ └── onboarding-actions.server.ts │ ├── user-authentication │ │ ├── user-authentication-client-schemas.ts │ │ ├── user-authentication-helpers.server.test.ts │ │ ├── user-authentication-loaders.server.ts │ │ ├── user-auth-session-factories.server.ts │ │ ├── user-authentication-session.server.ts │ │ ├── clerk-sdk.server.ts │ │ └── awaiting-email-confirmation.tsx │ ├── not-found │ │ ├── not-found-component.test.tsx │ │ └── not-found-component.tsx │ ├── monitoring │ │ ├── monitoring-helpers.client.ts │ │ └── monitoring-helpers.server.ts │ └── user-profile │ │ ├── user-profile-factories.server.ts │ │ └── user-profile-helpers.server.ts ├── components │ ├── disableable-link.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── input.tsx │ │ ├── sonner.tsx │ │ ├── checkbox.tsx │ │ ├── typography.tsx │ │ ├── popover.tsx │ │ ├── avatar.tsx │ │ ├── scroll-area.tsx │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── accordion.tsx │ │ └── card.tsx │ ├── disableable-link.test.tsx │ ├── text.test.tsx │ ├── text.tsx │ ├── general-error-boundary.tsx │ └── card-pagination.tsx ├── hooks │ ├── use-promise.ts │ ├── use-effect-once.test.ts │ ├── use-effect-once.ts │ ├── use-toast.ts │ └── use-promise.test.ts ├── styles │ ├── dark.css │ └── tailwind.css ├── https.ts ├── https.test.ts ├── entry.client.tsx ├── database.server.ts └── entry.server.tsx ├── .prettierignore ├── templates ├── app │ └── features │ │ └── feature │ │ ├── feature-component.hbs │ │ ├── feature-component.test.hbs │ │ └── feature-model.server.hbs └── playwright │ └── e2e │ └── feature │ └── feature.spec.hbs ├── .github └── dependabot.yml ├── commitlint.config.cjs ├── .env.example ├── .gitignore ├── components.json ├── prettier.config.cjs ├── tsconfig.json ├── playwright └── e2e │ ├── onboarding │ └── onboarding.spec.ts │ ├── user-authentication │ ├── logout.spec.ts │ └── login.spec.ts │ ├── settings │ └── settings.spec.ts │ ├── organizations │ ├── organizations.spec.ts │ └── organizations-slug.spec.ts │ ├── not-found │ └── not-found.spec.ts │ └── landing │ └── landing.spec.ts ├── LICENSE ├── vite.config.ts ├── .eslintrc.cjs ├── tailwind.config.ts ├── prisma └── seed.ts └── playwright.config.ts /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit ${1} 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run type-check && npm run lint 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["formik", "Hesters"] 3 | } 4 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/avatar.png -------------------------------------------------------------------------------- /public/magic-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/magic-icon.png -------------------------------------------------------------------------------- /public/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/favicons/favicon.ico -------------------------------------------------------------------------------- /public/french-house-stack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/french-house-stack.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/* 2 | **/build/* 3 | **/.cache/* 4 | **/coverage/* 5 | **/playwright-report/* 6 | mockServiceWorker.js -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/locales/en/sidebar.json: -------------------------------------------------------------------------------- 1 | { 2 | "close-sidebar": "Close sidebar", 3 | "open-sidebar": "Open sidebar", 4 | "sidebar": "Sidebar" 5 | } -------------------------------------------------------------------------------- /public/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janhesters/french-house-stack/HEAD/public/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /app/utils/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Arbitrary factory function for object of shape `Shape`. 3 | */ 4 | export type Factory = (object?: Partial) => Shape; 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | yarn.lock 4 | package-lock.json 5 | public 6 | coverage 7 | templates 8 | build 9 | playwright-report 10 | test-results -------------------------------------------------------------------------------- /public/locales/en/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "account": "Account", 3 | "profile": "Profile", 4 | "settings": "Settings", 5 | "settings-navigation": "Settings Navigation" 6 | } -------------------------------------------------------------------------------- /public/locales/en/drag-and-drop.json: -------------------------------------------------------------------------------- 1 | { 2 | "drag-and-drop-label": "or drag and drop", 3 | "file-types-label": "PNG, JPG, GIF up to 10MB", 4 | "upload-label": "Upload a file" 5 | } -------------------------------------------------------------------------------- /app/utils/shadcn-ui.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /templates/app/features/feature/feature-component.hbs: -------------------------------------------------------------------------------- 1 | export type {{pascalCase name}}ComponentProps = {}; 2 | 3 | export function {{pascalCase name}}Component(props: {{pascalCase name}}ComponentProps) { 4 | return null; 5 | } -------------------------------------------------------------------------------- /public/locales/en/organization-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": "General", 3 | "organization-settings": "Organization settings", 4 | "settings-navigation": "Organization settings navigation", 5 | "team-members": "Team members" 6 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | commit-message: 8 | prefix: 'build' 9 | labels: 10 | - 'build' 11 | -------------------------------------------------------------------------------- /app/test/mocks/handlers/magic.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw'; 2 | 3 | export const magicHandlers = [ 4 | http.post('https://api.magic.link/v2/admin/auth/user/logout', () => 5 | HttpResponse.json({ message: 'success' }, { status: 200 }), 6 | ), 7 | ]; 8 | -------------------------------------------------------------------------------- /public/locales/en/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "404-error": "404 Error", 3 | "app-name": "French House Stack", 4 | "go-back-home": "Go back home", 5 | "page-not-found": "Page not found.", 6 | "sorry-we-could-not-find-page": "Sorry, we couldn't find the page you're looking for." 7 | } -------------------------------------------------------------------------------- /public/locales/en/organizations.json: -------------------------------------------------------------------------------- 1 | { 2 | "create-organization": "Create organization", 3 | "home": "Home", 4 | "organizations": "Organizations", 5 | "select-an-organization": "Select an organization", 6 | "settings": "Settings", 7 | "team-members": "Team members" 8 | } -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'references-empty': [1, 'never'], 5 | 'footer-max-line-length': [0, 'always'], 6 | 'body-max-line-length': [0, 'always'], 7 | }, 8 | }; 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /public/locales/de/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "404-error": "404 Error", 3 | "app-name": "French House Stack", 4 | "go-back-home": "Zurück zur Startseite", 5 | "page-not-found": "Seite nicht gefunden.", 6 | "sorry-we-could-not-find-page": "Sorry, wir konnten die Seite nicht finden nach der du suchst." 7 | } -------------------------------------------------------------------------------- /public/locales/en/landing.json: -------------------------------------------------------------------------------- 1 | { 2 | "dj-image-alt": "A DJ by https://unsplash.com/@emilianovittoriosi", 3 | "follow-ten-x-dev": "Follow @tenxdev on X", 4 | "register": "Register", 5 | "stack-instructions": "Check the <1><2>README.md file for instructions on how to work with this project." 6 | } -------------------------------------------------------------------------------- /app/routes/settings._index.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | import { redirect } from '@remix-run/node'; 3 | 4 | export const loader = async ({ request }: LoaderFunctionArgs) => { 5 | const url = new URL(request.url).pathname; 6 | return redirect(url + '/profile'); 7 | }; 8 | -------------------------------------------------------------------------------- /app/routes/onboarding._index.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | import { redirect } from '@remix-run/node'; 3 | 4 | export const loader = async ({ request }: LoaderFunctionArgs) => { 5 | const url = new URL(request.url).pathname; 6 | return redirect(url + '/user-profile'); 7 | }; 8 | -------------------------------------------------------------------------------- /app/features/organizations/organizations-constants.ts: -------------------------------------------------------------------------------- 1 | export const ORGANIZATION_MEMBERSHIP_ROLES = { 2 | MEMBER: 'member', 3 | ADMIN: 'admin', 4 | OWNER: 'owner', 5 | } as const; 6 | 7 | export type OrganizationMembershipRole = 8 | (typeof ORGANIZATION_MEMBERSHIP_ROLES)[keyof typeof ORGANIZATION_MEMBERSHIP_ROLES]; 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MAGIC_PUBLISHABLE_KEY=pk_live_ 2 | MAGIC_SECRET_KEY=sk_live_ 3 | SESSION_SECRET=a-sufficiently-long-secret 4 | DATABASE_URL="file:./data.db?connection_limit=1" 5 | SEED_USER_EMAIL=your-email 6 | SEED_USER_DID=your-user-did-from-magic-starts-with-did:ethr 7 | SENTRY_DSN=your-sentry-dsn 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | coverage 4 | 5 | /.cache 6 | /build 7 | /public/build 8 | .env 9 | .cache 10 | tsconfig.tsbuildinfo 11 | 12 | /prisma/data.db 13 | /prisma/data.db-journal 14 | 15 | .DS_Store 16 | /test-results/ 17 | /playwright-report/ 18 | /blob-report/ 19 | /playwright/.cache/ 20 | .zed/ 21 | yarn.lock 22 | package-lock.json 23 | -------------------------------------------------------------------------------- /public/locales/en/header.json: -------------------------------------------------------------------------------- 1 | { 2 | "go-back": "Go back", 3 | "log-out": "Log out", 4 | "open-user-menu": "Open user menu", 5 | "search": "Search", 6 | "search-placeholder": "Search ...", 7 | "settings": "Your settings", 8 | "view-n-new-notifications": "View {{count}} new notifications", 9 | "view-notifications": "View notifications" 10 | } -------------------------------------------------------------------------------- /public/locales/en/pagination.json: -------------------------------------------------------------------------------- 1 | { 2 | "go-to-next-page": "Go to next page", 3 | "go-to-previous-page": "Go to previous page", 4 | "more": "More pages", 5 | "next": "Next", 6 | "pagination": "Pagination", 7 | "previous": "Previous", 8 | "showing-min-to-max-of-total-items-results": "Showing <1>{{min}} to <1>{{max}} of <1>{{totalItemCount}} results" 9 | } -------------------------------------------------------------------------------- /app/routes/logout.ts: -------------------------------------------------------------------------------- 1 | import { type ActionFunctionArgs, redirect } from '@remix-run/node'; 2 | 3 | import { logout } from '~/features/user-authentication/user-authentication-helpers.server'; 4 | 5 | export function loader() { 6 | return redirect('/'); 7 | } 8 | 9 | export async function action({ request }: ActionFunctionArgs) { 10 | return await logout(request); 11 | } 12 | -------------------------------------------------------------------------------- /app/test/setup-test-environment.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import '@testing-library/jest-dom/vitest'; 3 | 4 | import { installGlobals } from '@remix-run/node'; 5 | 6 | installGlobals(); 7 | 8 | // See https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#configuring-your-testing-environment. 9 | // @ts-ignore 10 | globalThis.IS_REACT_ACT_ENVIRONMENT = true; 11 | -------------------------------------------------------------------------------- /app/test/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler } from 'msw'; 2 | import { setupWorker } from 'msw/browser'; 3 | 4 | import { clerkHandlers } from './handlers/clerk'; 5 | 6 | const handlers: RequestHandler[] = [ 7 | /* ... add your handlers for client side request mocking here ... */ 8 | ...clerkHandlers, 9 | ]; 10 | 11 | export const worker = setupWorker(...handlers); 12 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "aliases": { 4 | "components": "~/components", 5 | "utils": "~/utils/shadcn-ui" 6 | }, 7 | "rsc": false, 8 | "style": "new-york", 9 | "tailwind": { 10 | "baseColor": "gray", 11 | "config": "tailwind.config.ts", 12 | "css": "app/styles/tailwind.css", 13 | "cssVariables": true, 14 | "prefix": "" 15 | }, 16 | "tsx": true 17 | } 18 | -------------------------------------------------------------------------------- /app/test/generate-random-did.server.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'node:crypto'; 2 | 3 | /** 4 | * Generates a random Clerk id. This function is to 5 | * _**generate fake test data only**_. We use clerk ids for user ids. 6 | * 7 | * @see https://clerk.com/docs/references/javascript/user/user 8 | * 9 | * @returns clerkId 10 | */ 11 | export function generateRandomClerkId() { 12 | return 'user_' + randomBytes(32).toString('hex').slice(0, 40); 13 | } 14 | -------------------------------------------------------------------------------- /app/routes/organizations.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | import { redirect } from '@remix-run/node'; 3 | 4 | import { requireOnboardedUserProfileExists } from '~/features/onboarding/onboarding-helpers.server'; 5 | 6 | export async function loader({ request }: LoaderFunctionArgs) { 7 | const user = await requireOnboardedUserProfileExists(request); 8 | return redirect( 9 | `/organizations/${user.memberships[0].organization.slug}/home`, 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/utils/async-for-each.server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, vi } from 'vitest'; 2 | 3 | import { asyncForEach } from './async-for-each.server'; 4 | 5 | describe('asyncForEach()', () => { 6 | test('given an array and a callback: calls the callback for each item in the array', async () => { 7 | const array = [1, 2, 3]; 8 | const callback = vi.fn(); 9 | 10 | await asyncForEach(array, callback); 11 | 12 | expect(callback).toHaveBeenCalledTimes(3); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSameLine: false, 4 | bracketSpacing: true, 5 | htmlWhitespaceSensitivity: 'css', 6 | insertPragma: false, 7 | jsxSingleQuote: false, 8 | plugins: ['prettier-plugin-tailwindcss'], 9 | printWidth: 80, 10 | proseWrap: 'always', 11 | quoteProps: 'as-needed', 12 | requirePragma: false, 13 | semi: true, 14 | singleQuote: true, 15 | tabWidth: 2, 16 | trailingComma: 'all', 17 | useTabs: false, 18 | }; 19 | -------------------------------------------------------------------------------- /app/features/localization/i18n.ts: -------------------------------------------------------------------------------- 1 | export const i18n = { 2 | // This is the list of languages your application supports. 3 | supportedLngs: ['en', 'de'], 4 | // This is the language you want to use in case if the user language is not 5 | // in the supportedLngs. 6 | fallbackLng: 'en', 7 | // The default namespace of i18next is "translation", but you can customize it 8 | // here. 9 | defaultNS: 'common', 10 | // Disabling suspense is recommended. 11 | react: { useSuspense: false }, 12 | }; 13 | -------------------------------------------------------------------------------- /app/features/settings/settings-client-schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const settingsUserProfileSchema = z.object({ 4 | name: z 5 | .string({ 6 | invalid_type_error: 'settings-user-profile:name-must-be-string', 7 | }) 8 | .trim() 9 | .min(2, 'settings-user-profile:name-min-length') 10 | .max(128, 'settings-user-profile:name-max-length'), 11 | intent: z.literal('update'), 12 | }); 13 | 14 | export const settingsAccountSchema = z.object({ intent: z.literal('delete') }); 15 | -------------------------------------------------------------------------------- /app/utils/throw-if-entity-is-missing.server.ts: -------------------------------------------------------------------------------- 1 | import { notFound } from './http-responses.server'; 2 | 3 | /** 4 | * Ensures that something exists. 5 | * 6 | * @param entity - The entity to check - possibly null or undefined. 7 | * @returns The same entity if it exists. 8 | * @throws A '404 not found' HTTP response if the entity is missing. 9 | */ 10 | export const throwIfEntityIsMissing = (entity: T | null) => { 11 | if (!entity) { 12 | throw notFound(); 13 | } 14 | 15 | return entity; 16 | }; 17 | -------------------------------------------------------------------------------- /app/utils/async-pipe.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { asyncPipe } from './async-pipe'; 4 | 5 | const asyncDouble = (n: number) => Promise.resolve(n * 2); 6 | const asyncInc = (n: number) => Promise.resolve(n + 1); 7 | 8 | describe('asyncPipe()', () => { 9 | test('given two promises: composes them in reverse mathematical order', async () => { 10 | const asyncIncDouble = asyncPipe(asyncInc, asyncDouble); 11 | 12 | expect(await asyncIncDouble(20)).toEqual(42); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /app/components/disableable-link.tsx: -------------------------------------------------------------------------------- 1 | import type { LinkProps } from '@remix-run/react'; 2 | import { Link } from '@remix-run/react'; 3 | 4 | export type DisableableLinkComponentProps = LinkProps & { disabled?: boolean }; 5 | 6 | export function DisableableLink(props: DisableableLinkComponentProps) { 7 | const { disabled, children, ...rest } = props; 8 | 9 | return disabled ? ( 10 | 11 | {children} 12 | 13 | ) : ( 14 | {children} 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/routes/organizations_.$organizationSlug.home.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | import { json } from '@remix-run/node'; 3 | 4 | import { GeneralErrorBoundary } from '~/components/general-error-boundary'; 5 | 6 | export function loader({ request }: LoaderFunctionArgs) { 7 | return json({ headerTitle: 'Home' }); 8 | } 9 | 10 | export default function OrganizationsHome() { 11 | return
Organizations Home
; 12 | } 13 | 14 | export function ErrorBoundary() { 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /public/favicons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#ffffff", 3 | "display": "standalone", 4 | "icons": [ 5 | { 6 | "sizes": "192x192", 7 | "src": "/android-chrome-192x192.png", 8 | "type": "image/png" 9 | }, 10 | { 11 | "sizes": "512x512", 12 | "src": "/android-chrome-512x512.png", 13 | "type": "image/png" 14 | } 15 | ], 16 | "name": "French House Stack", 17 | "short_name": "FHS", 18 | "theme_color": "#ef4444" 19 | } -------------------------------------------------------------------------------- /public/locales/en/user-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "Cancel", 3 | "dismiss-alert": "Dismiss notification", 4 | "email": "Email", 5 | "name": "Name", 6 | "name-required-and-constraints": "A name is required and must be at least 3 characters long.", 7 | "profile": "Profile", 8 | "public-information": "This information will be displayed publicly so be careful what you share.", 9 | "save": "Save", 10 | "saving": "Saving ...", 11 | "settings": "Settings", 12 | "success": "Success", 13 | "successful-save": "Profile successfully saved" 14 | } -------------------------------------------------------------------------------- /app/test/mocks/msw-utils.server.ts: -------------------------------------------------------------------------------- 1 | import { http, passthrough } from 'msw'; 2 | 3 | const REMIX_DEV_PING = new URL( 4 | process?.env?.REMIX_DEV_ORIGIN || 'http://test-origin', 5 | ); 6 | REMIX_DEV_PING.pathname = '/ping'; 7 | 8 | /** 9 | * Lets MSW forward internal "dev ready" messages on `/ping`. 10 | * 11 | * @see https://remix.run/docs/en/main/other-api/dev#how-to-set-up-msw 12 | * 13 | * @returns A response object for the remix ping request. 14 | */ 15 | export const remixPingHandler = http.post(REMIX_DEV_PING.href, () => 16 | passthrough(), 17 | ); 18 | -------------------------------------------------------------------------------- /app/hooks/use-promise.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | type Reference = [ 4 | Promise, 5 | (value: T | PromiseLike) => void, 6 | (reason?: any) => void, 7 | ]; 8 | 9 | /** 10 | * @returns An array containing [promise, resolve, reject]. 11 | */ 12 | export function usePromise() { 13 | const reference = [] as unknown as Reference; 14 | const container = useRef(reference); 15 | 16 | reference[0] = new Promise((resolve, reject) => { 17 | reference[1] = resolve; 18 | reference[2] = reject; 19 | }); 20 | 21 | return container.current; 22 | } 23 | -------------------------------------------------------------------------------- /app/utils/get-root-directory.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs'; 2 | import { dirname, join } from 'node:path'; 3 | 4 | export const getRootDirectory = (directory: string): string => { 5 | let currentPath = directory; 6 | while (!existsSync(join(currentPath, 'package.json'))) { 7 | const parentDirectory = dirname(currentPath); 8 | if (parentDirectory === currentPath) { 9 | throw new Error( 10 | 'Reached the filesystem root without finding package.json.', 11 | ); 12 | } 13 | currentPath = parentDirectory; 14 | } 15 | return currentPath; 16 | }; 17 | -------------------------------------------------------------------------------- /app/hooks/use-effect-once.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | 4 | import { useEffectOnce } from './use-effect-once'; 5 | 6 | describe('useEffectOnce()', () => { 7 | it('runs the effect exactly once', () => { 8 | const effect = vi.fn(); 9 | 10 | expect(effect).not.toHaveBeenCalled(); 11 | 12 | const { rerender } = renderHook(() => useEffectOnce(effect)); 13 | 14 | expect(effect).toHaveBeenCalledOnce(); 15 | 16 | rerender(); 17 | 18 | expect(effect).toHaveBeenCalledOnce(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /public/locales/en/settings-user-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "This is how others will see you in this app.", 3 | "name-max-length": "Your name must be at most 128 characters long.", 4 | "name-min-length": "Your name must be at least 2 characters long.", 5 | "name-must-be-string": "Your public name must be a string.", 6 | "save": "Save", 7 | "saving": "Saving ...", 8 | "title": "Profile", 9 | "user-name-description": "Please enter your full name for public display within your organizations.", 10 | "user-name-label": "Name", 11 | "user-name-placeholder": "Your full name ...", 12 | "user-profile": "User profile", 13 | "user-profile-updated": "Profile has been updated" 14 | } -------------------------------------------------------------------------------- /app/styles/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: 0 0% 3.9%; 3 | --foreground: 0 0% 98%; 4 | 5 | --card: 0 0% 3.9%; 6 | --card-foreground: 0 0% 98%; 7 | 8 | --popover: 0 0% 3.9%; 9 | --popover-foreground: 0 0% 98%; 10 | 11 | --primary: 0 84% 60%; 12 | --primary-foreground: 0 85.7% 97.3%; 13 | 14 | --secondary: 0 0% 14.9%; 15 | --secondary-foreground: 0 0% 98%; 16 | 17 | --muted: 0 0% 14.9%; 18 | --muted-foreground: 0 0% 63.9%; 19 | 20 | --accent: 0 0% 14.9%; 21 | --accent-foreground: 0 0% 98%; 22 | 23 | --destructive: 0 62.8% 30.6%; 24 | --destructive-foreground: 0 0% 98%; 25 | 26 | --border: 0 0% 14.9%; 27 | --input: 0 0% 14.9%; 28 | --ring: 0 84% 60%; 29 | 30 | color-scheme: dark; 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": ".", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "isolatedModules": true, 8 | "jsx": "react-jsx", 9 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 10 | "module": "ESNext", 11 | "moduleResolution": "Bundler", 12 | // Remix takes care of building everything in `remix build`. 13 | "noEmit": true, 14 | "paths": { 15 | "~/*": ["./app/*"] 16 | }, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "target": "ES2022", 21 | "types": ["@remix-run/node", "vite/client"] 22 | }, 23 | "include": ["env.d.ts", "**/*.ts", "**/*.tsx"] 24 | } 25 | -------------------------------------------------------------------------------- /app/features/organizations/organizations-client-schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const newOrganizationSchema = z.object({ 4 | name: z 5 | .string({ 6 | invalid_type_error: 'organizations-new:name-must-be-string', 7 | }) 8 | .trim() 9 | .min(3, 'organizations-new:name-min-length') 10 | .max(255, 'organizations-new:name-max-length'), 11 | intent: z.literal('create'), 12 | }); 13 | 14 | export const organizationProfileSchema = z.object({ 15 | name: z 16 | .string({ 17 | invalid_type_error: 'organization-profile:name-must-be-string', 18 | }) 19 | .trim() 20 | .min(3, 'organization-profile:name-min-length') 21 | .max(255, 'organization-profile:name-max-length'), 22 | intent: z.literal('update'), 23 | }); 24 | -------------------------------------------------------------------------------- /playwright/e2e/onboarding/onboarding.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | import { deleteUserProfileFromDatabaseById } from '~/features/user-profile/user-profile-model.server'; 4 | 5 | import { getPath, loginAndSaveUserProfileToDatabase } from '../../utils'; 6 | 7 | test.describe('onboarding page', () => { 8 | test('given a logged in user that is NOT onboarded: redirects the user to the user profile onboarding page', async ({ 9 | page, 10 | }) => { 11 | const { id } = await loginAndSaveUserProfileToDatabase({ name: '', page }); 12 | 13 | await page.goto(`/onboarding`); 14 | expect(getPath(page)).toEqual('/onboarding/user-profile'); 15 | 16 | await page.close(); 17 | await deleteUserProfileFromDatabaseById(id); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /app/features/onboarding/onboarding-client-schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const onboardingUserProfileSchema = z.object({ 4 | name: z 5 | .string({ 6 | invalid_type_error: 'onboarding-user-profile:name-must-be-string', 7 | }) 8 | .trim() 9 | .min(2, 'onboarding-user-profile:name-min-length') 10 | .max(128, 'onboarding-user-profile:name-max-length'), 11 | intent: z.literal('create'), 12 | }); 13 | 14 | export const onboardingOrganizationSchema = z.object({ 15 | name: z 16 | .string({ 17 | invalid_type_error: 'onboarding-organization:name-must-be-string', 18 | }) 19 | .trim() 20 | .min(3, 'onboarding-organization:name-min-length') 21 | .max(255, 'onboarding-organization:name-max-length'), 22 | intent: z.literal('create'), 23 | }); 24 | -------------------------------------------------------------------------------- /public/locales/en/organizations-new.json: -------------------------------------------------------------------------------- 1 | { 2 | "create-new-organization": "Create a new organization", 3 | "name-max-length": "Your organization name must be at most 255 characters long.", 4 | "name-min-length": "Your organization name must be at least 3 characters long.", 5 | "name-must-be-string": "Your organization's name must be a string.", 6 | "organization-card-description": "You can invite other users to join your organization later.", 7 | "organization-card-title": "Create your organization", 8 | "organization-name-description": "Please enter the name of your organization.", 9 | "organization-name-label": "Organization name", 10 | "organization-name-placeholder": "Your organization's name ...", 11 | "organizations": "Organizations", 12 | "save": "Save", 13 | "saving": "Saving ..." 14 | } -------------------------------------------------------------------------------- /app/test/react-test-utils.tsx: -------------------------------------------------------------------------------- 1 | import type { RenderOptions } from '@testing-library/react'; 2 | import { render } from '@testing-library/react'; 3 | import type { ReactElement } from 'react'; 4 | import { I18nextProvider } from 'react-i18next'; 5 | 6 | import i18next from './i18n'; 7 | 8 | const customRender = ( 9 | ui: ReactElement, 10 | options?: Omit, 11 | ) => 12 | render(ui, { 13 | wrapper: ({ children }) => ( 14 | {children} 15 | ), 16 | ...options, 17 | }); 18 | 19 | // re-export everything 20 | export * from '@testing-library/react'; 21 | 22 | // override render method 23 | export { customRender as render }; 24 | export { createRemixStub } from '@remix-run/testing'; 25 | export { default as userEvent } from '@testing-library/user-event'; 26 | -------------------------------------------------------------------------------- /app/features/user-authentication/user-authentication-client-schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const loginFormSchema = z.object({ 4 | email: z 5 | .string({ invalid_type_error: 'login:email-must-be-string' }) 6 | .min(1, 'login:email-required') 7 | .email('login:email-invalid'), 8 | intent: z.literal('emailLogin'), 9 | }); 10 | 11 | export const registrationFormSchema = z.object({ 12 | acceptedTerms: z 13 | .preprocess(value => value === 'true' || value === true, z.boolean()) 14 | .refine(value => value === true, { 15 | message: 'register:terms-must-be-accepted', 16 | }), 17 | email: z 18 | .string({ invalid_type_error: 'register:email-must-be-string' }) 19 | .min(1, 'register:email-required') 20 | .email('register:email-invalid'), 21 | intent: z.literal('emailRegistration'), 22 | }); 23 | -------------------------------------------------------------------------------- /templates/app/features/feature/feature-component.test.hbs: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { render, screen } from '~/test/react-test-utils'; 4 | import type { Factory } from '~/utils/types'; 5 | 6 | import type { {{pascalCase name}}ComponentProps } from './{{kebabCase name}}-component'; 7 | import { {{pascalCase name}}Component } from './{{kebabCase name}}-component'; 8 | 9 | const createProps: Factory<{{pascalCase name}}ComponentProps> = ({ ...props } = {}) => ({ 10 | ...props, 11 | }); 12 | 13 | describe('{{pascalCase name}} component', () => { 14 | test('renders correctly', () => { 15 | const props = createProps(); 16 | 17 | render(<{{pascalCase name}}Component {...props} />); 18 | 19 | expect( 20 | screen.getByRole('heading', { name: /{{name}}/i, level: 1 }), 21 | ).toBeInTheDocument(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /app/utils/async-for-each.server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Iterates over an array asynchronously and executes a callback for each item. 3 | * 4 | * @param array - The array to iterate over. 5 | * @param callback - The promise callback to execute for each element. 6 | * 7 | * @example 8 | * ```ts 9 | * const waitFor = ms => new Promise(r => setTimeout(r, ms)); 10 | * 11 | * const start = async () => { 12 | * await asyncForEach([1, 2, 3], async num => { 13 | * await waitFor(3000); 14 | * console.log(num); 15 | * }); 16 | * console.log('Done'); 17 | * }; 18 | * 19 | * start(); 20 | * ``` 21 | */ 22 | export async function asyncForEach( 23 | array: T[], 24 | callback: (item: T, index: number, array: T[]) => Promise, 25 | ) { 26 | for (let index = 0; index < array.length; index++) { 27 | await callback(array[index], index, array); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/utils/get-search-parameter-from-request.server.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from 'ramda'; 2 | 3 | /** 4 | * Create a URL instance from a Request object. 5 | * 6 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request|Request} on MDN. 7 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL|Url} on MDN. 8 | * 9 | * @param request - A resource request from the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). 10 | * @returns A URL interface. 11 | */ 12 | export const requestToUrl = (request: Request) => new URL(request.url); 13 | 14 | export const getSearchParameterFromUrl = 15 | (searchParameter: string) => (url: URL) => 16 | url.searchParams.get(searchParameter); 17 | 18 | export const getSearchParameterFromRequest = (searchParameter: string) => 19 | pipe(requestToUrl, getSearchParameterFromUrl(searchParameter)); 20 | -------------------------------------------------------------------------------- /app/features/not-found/not-found-component.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { createRemixStub, render, screen } from '~/test/react-test-utils'; 4 | 5 | import { NotFoundComponent } from './not-found-component'; 6 | 7 | describe('NotFound component', () => { 8 | test('given a link: renders error messages and the correct link', async () => { 9 | const path = '/some-non-existent-page'; 10 | const RemixStub = createRemixStub([ 11 | { path, Component: props => }, 12 | ]); 13 | 14 | render(); 15 | 16 | expect( 17 | screen.getByRole('heading', { level: 1, name: /not found/i }), 18 | ).toBeInTheDocument(); 19 | expect(screen.getByRole('link', { name: /home/i })).toHaveAttribute( 20 | 'href', 21 | '/', 22 | ); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /public/locales/en/onboarding-user-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "name-max-length": "Your name must be at most 128 characters long.", 3 | "name-min-length": "Your name must be at least 2 characters long.", 4 | "name-must-be-string": "Your public name must be a string.", 5 | "onboarding": "Onboarding", 6 | "onboarding-progress": "Onboarding progress", 7 | "onboarding-user-profile": "Onboarding User Profile", 8 | "organization": "Organization", 9 | "profile-card-description": "Welcome to the French House Stack! Please create your user profile to get started.", 10 | "profile-card-title": "Create your profile", 11 | "save": "Save", 12 | "saving": "Saving ...", 13 | "user-name-description": "Please enter your full name for public display within your organization.", 14 | "user-name-label": "Name", 15 | "user-name-placeholder": "Your full name ...", 16 | "user-profile": "User profile" 17 | } -------------------------------------------------------------------------------- /app/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from '@radix-ui/react-label'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import * as React from 'react'; 4 | 5 | import { cn } from '~/utils/shadcn-ui'; 6 | 7 | const labelVariants = cva( 8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-read-only:cursor-not-allowed peer-read-only:opacity-70 peer-disabled:opacity-70', 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /app/test/mocks/msw-utils.ts: -------------------------------------------------------------------------------- 1 | import type { UnhandledRequestCallback } from 'node_modules/msw/lib/core/utils/request/onUnhandledRequest'; 2 | 3 | /** 4 | * Callback function to handle unhandled requests in MSW (Mock Service Worker). 5 | * 6 | * @param request - The unhandled request object. 7 | */ 8 | export const onUnhandledRequest: UnhandledRequestCallback = request => { 9 | // Opt out of request to localhost on the client and server. 10 | if (new URL(request.url).href.startsWith('http://localhost')) { 11 | return; 12 | } 13 | 14 | console.warn( 15 | '[MSW] Warning: captured a request without a matching request handler:\n\n', 16 | ` • ${request.method} ${new URL(request.url).href}\n\n`, 17 | 'If you still wish to intercept this unhandled request, please create a request handler for it.\n', 18 | 'Read more: https://mswjs.io/docs/getting-started/mocks', 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as SeparatorPrimitive from '@radix-ui/react-separator'; 2 | import * as React from 'react'; 3 | 4 | import { cn } from '~/utils/shadcn-ui'; 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = 'horizontal', decorative = true, ...props }, 12 | ref, 13 | ) => ( 14 | 25 | ), 26 | ); 27 | Separator.displayName = SeparatorPrimitive.Root.displayName; 28 | 29 | export { Separator }; 30 | -------------------------------------------------------------------------------- /app/hooks/use-effect-once.ts: -------------------------------------------------------------------------------- 1 | import type { EffectCallback } from 'react'; 2 | import { useEffect, useRef } from 'react'; 3 | 4 | /** 5 | * Accepts a function that contains imperative, possibly effectful code. 6 | * It executes that function exactly once. 7 | * 8 | * Can be used to avoid shooting us in the foot with React 18 and strict mode. 9 | * @see https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state 10 | * @see https://github.com/reactwg/react-18/discussions/18 11 | * 12 | * @param effect Imperative function that can return a cleanup function 13 | */ 14 | export function useEffectOnce(effect: EffectCallback) { 15 | const isFirstMount = useRef(true); 16 | 17 | useEffect(() => { 18 | if (isFirstMount.current) { 19 | isFirstMount.current = false; 20 | 21 | return effect(); 22 | } 23 | 24 | // eslint-disable-next-line react-hooks/exhaustive-deps 25 | }, []); 26 | } 27 | -------------------------------------------------------------------------------- /public/locales/en/onboarding-organization.json: -------------------------------------------------------------------------------- 1 | { 2 | "name-max-length": "Your organization name must be at most 255 characters long.", 3 | "name-min-length": "Your organization name must be at least 3 characters long.", 4 | "name-must-be-string": "Your organization's name must be a string.", 5 | "onboarding": "Onboarding", 6 | "onboarding-organization": "Onboarding Organization", 7 | "onboarding-progress": "Onboarding progress", 8 | "organization": "Organization", 9 | "organization-card-description": "You can invite other users to join your organization later.", 10 | "organization-card-title": "Create your organization", 11 | "organization-name-description": "Please enter the name of your organization.", 12 | "organization-name-label": "Organization name", 13 | "organization-name-placeholder": "Your organization's name ...", 14 | "save": "Save", 15 | "saving": "Saving ...", 16 | "user-profile": "User profile" 17 | } -------------------------------------------------------------------------------- /app/utils/throw-if-entity-is-missing.server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { createPopulatedOrganization } from '~/features/organizations/organizations-factories.server'; 4 | 5 | import { throwIfEntityIsMissing } from './throw-if-entity-is-missing.server'; 6 | 7 | describe('throwIfEntityIsMissing()', () => { 8 | test('given an an entity: returns the entity', () => { 9 | const organization = createPopulatedOrganization(); 10 | 11 | const actual = throwIfEntityIsMissing(organization); 12 | const expected = organization; 13 | 14 | expect(actual).toEqual(expected); 15 | }); 16 | 17 | test('given null: throws a 404 not found error', () => { 18 | expect.assertions(1); 19 | 20 | try { 21 | throwIfEntityIsMissing(null); 22 | } catch (error) { 23 | if (error instanceof Response) { 24 | expect(error.status).toEqual(404); 25 | } 26 | } 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /app/utils/trace.ts: -------------------------------------------------------------------------------- 1 | import { tap } from 'ramda'; 2 | 3 | /** 4 | * A utility higher-order function that can be used with point-free style to log 5 | * the contents of a given value. 6 | * 7 | * @param message - The message to prefix the logged out value with. 8 | * @returns A function that takes in a value and returns it while logging out 9 | * the value with the given message. 10 | * 11 | * @example 12 | * ```ts 13 | * import { map } from 'ramda'; 14 | * 15 | * const multiplyByTen = map(x => x * 10); 16 | * const subtractFive = map(x => x - 5); 17 | * 18 | * const traceProcess = pipe( 19 | * multiplyByTen, 20 | * trace('Value after multiplying by 10'), 21 | * subtractFive 22 | * ); 23 | * 24 | * console.log(traceProcess([1, 2, 3])); 25 | * // ↵ "Value after multiplying by 10: [10, 20, 30]" 26 | * ``` 27 | */ 28 | export const trace: (message: string) => (value: T) => T = message => 29 | tap(x => console.log(message, x)); 30 | -------------------------------------------------------------------------------- /app/utils/combine-headers.server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Combines multiple Headers objects into a single Headers instance. 3 | * Iterates through each provided Headers object, appending their key-value 4 | * pairs to a new Headers instance. Null or undefined Headers objects are 5 | * ignored. This function is useful for merging headers from different sources. 6 | * 7 | * @param headers - An array of Headers objects, which can include null or 8 | * undefined values. 9 | * @returns - A new Headers instance containing all key-value pairs from the 10 | * input headers. 11 | */ 12 | export function combineHeaders( 13 | ...headers: Array 14 | ) { 15 | const combined = new Headers(); 16 | 17 | for (const header of headers) { 18 | if (!header) continue; 19 | 20 | for (const [key, value] of new Headers(header).entries()) { 21 | combined.append(key, value); 22 | } 23 | } 24 | 25 | return combined; 26 | } 27 | -------------------------------------------------------------------------------- /app/hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { toast as showToast } from 'sonner'; 3 | 4 | import type { Toast } from '~/utils/toast.server'; 5 | 6 | /** 7 | * Custom hook for displaying a toast notification. 8 | * If a `toast` object is provided, it triggers a toast notification with the 9 | * specified type, title, and description after a brief delay. The toast is 10 | * displayed using the `showToast` function, which is based on the `toast.type`. 11 | * 12 | * @param toast - Optional. The toast object containing the type, title, and 13 | * description of the notification. If null or undefined, no toast is shown. 14 | */ 15 | export function useToast(toast?: Toast | null) { 16 | useEffect(() => { 17 | if (toast) { 18 | setTimeout(() => { 19 | showToast[toast.type](toast.title, { 20 | id: toast.id, 21 | description: toast.description, 22 | }); 23 | }, 0); 24 | } 25 | }, [toast]); 26 | } 27 | -------------------------------------------------------------------------------- /app/https.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@remix-run/node'; 2 | 3 | /** 4 | * This functions enforces https protocol in production evironments and would redirect the user 5 | * to a url using the https protocol if http and not localhost 6 | * 7 | * @param request - the incoming request 8 | * @throw a redirect to the https route if current protocol used is http and not localhost 9 | * being used 10 | */ 11 | export const enforceHttps = (request: Request) => { 12 | const url = new URL(request.url); 13 | const hostname = url.hostname; 14 | const protocol = request.headers.get('X-Forwarded-Proto') ?? url.protocol; 15 | 16 | url.host = 17 | request.headers.get('X-Forwarded-Host') ?? 18 | request.headers.get('host') ?? 19 | url.host; 20 | url.protocol = 'https:'; 21 | 22 | if (protocol === 'http' && hostname !== 'localhost') { 23 | throw redirect(url.toString(), { 24 | headers: { 25 | 'X-Forwarded-Proto': 'https', 26 | }, 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /app/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '~/utils/shadcn-ui'; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const inputClassName = 9 | 'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground read-only:cursor-not-allowed read-only:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'; 10 | 11 | const Input = React.forwardRef( 12 | ({ className, type, ...props }, ref) => { 13 | return ( 14 | 20 | ); 21 | }, 22 | ); 23 | Input.displayName = 'Input'; 24 | 25 | export { Input, inputClassName }; 26 | -------------------------------------------------------------------------------- /app/features/user-authentication/user-authentication-helpers.server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { createUserWithOrganizations } from '~/test/test-utils'; 4 | 5 | import { getLoginRedirectUrl } from './user-authentication-helpers.server'; 6 | 7 | describe('getLoginRedirectUrl()', () => { 8 | test("given a user profile with organizations: returns the first organization's home url", () => { 9 | const user = createUserWithOrganizations(); 10 | 11 | const actual = getLoginRedirectUrl(user); 12 | const expected = `/organizations/${user.memberships[0].organization.slug}/home`; 13 | 14 | expect(actual).toBe(expected); 15 | }); 16 | 17 | test('given a user profile without organizations: returns the onboarding page url', () => { 18 | const user = createUserWithOrganizations({ memberships: [] }); 19 | 20 | const actual = getLoginRedirectUrl(user); 21 | const expected = '/onboarding'; 22 | 23 | expect(actual).toBe(expected); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /app/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | // import { useTheme } from 'next-themes'; 2 | import { Toaster as Sonner } from 'sonner'; 3 | 4 | type ToasterProps = React.ComponentProps; 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | return ( 8 | 24 | ); 25 | }; 26 | 27 | export { Toaster }; 28 | -------------------------------------------------------------------------------- /app/utils/to-form-data.ts: -------------------------------------------------------------------------------- 1 | export type Payload = { 2 | [key: string]: string | Blob | string[]; 3 | }; 4 | 5 | /** 6 | * Converts a payload object into a FormData instance. 7 | * 8 | * @param payload - An object with string keys and values of either `string`, 9 | * `Blob`, or an array of `string`. 10 | * @returns A FormData instance populated with the provided payload. 11 | * 12 | * @example 13 | * const payload = { 14 | * text: 'Hello', 15 | * file: new Blob(['content'], { type: 'text/plain' }), 16 | * questions: ['What is up?', 'Can you tell me?'], 17 | * }; 18 | * const formData = toFormData(payload); 19 | */ 20 | export function toFormData(payload: Payload) { 21 | const formData = new FormData(); 22 | 23 | Object.entries(payload).forEach(([key, value]) => { 24 | if (Array.isArray(value)) { 25 | value.forEach(element => { 26 | formData.append(key, element); 27 | }); 28 | } else { 29 | formData.append(key, value); 30 | } 31 | }); 32 | 33 | return formData; 34 | } 35 | -------------------------------------------------------------------------------- /public/locales/en/accept-membership-invite.json: -------------------------------------------------------------------------------- 1 | { 2 | "accept-invite": "Accept invite", 3 | "accept-invite-instructions": "Click the button below to sign up. By using this link you will automatically join the correct organization.", 4 | "already-member-toast-description": "You are already a member of {{organizationName}}", 5 | "already-member-toast-title": "Already a member", 6 | "invite-link-invalid-toast-description": "The invite link is invalid or has expired", 7 | "invite-link-invalid-toast-title": "Failed to accept invite", 8 | "invite-link-valid-toast-description": "Please register or log in to accept the invitation", 9 | "invite-link-valid-toast-title": "Invitation link is valid", 10 | "invite-you-to-join": "{{inviterName}} invites you to join {{organizationName}}", 11 | "join-success-toast-description": "You are now a member of {{organizationName}}", 12 | "join-success-toast-title": "Successfully joined organization", 13 | "page-title": "Invitation", 14 | "welcome-to-app-name": "Welcome to {{appName}}" 15 | } -------------------------------------------------------------------------------- /app/features/not-found/not-found-component.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { buttonVariants } from '~/components/ui/button'; 5 | 6 | export function NotFoundComponent() { 7 | const { t } = useTranslation('common'); 8 | 9 | return ( 10 |
11 |
12 |

{t('404-error')}

13 | 14 |

15 | {t('page-not-found')} 16 |

17 | 18 |

19 | {t('sorry-we-could-not-find-page')} 20 |

21 | 22 |
23 | 24 | {t('go-back-home')} 25 | 26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2024 Ten X Dev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /app/features/localization/get-page-title.server.ts: -------------------------------------------------------------------------------- 1 | import type { TFunction, TOptions } from 'i18next'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | type $Dictionary = { [key: string]: T }; 5 | 6 | /** 7 | * Helper function to get the page title. 8 | * 9 | * @param tFunc - An i18next translation function. 10 | * @param tKey - Translation key or key options pair to add a translated prefix 11 | * title. 12 | * @param prefix - A custom prefix to add to the title. 13 | * @returns A string containing the page title. 14 | */ 15 | export function getPageTitle( 16 | t: TFunction, 17 | tKey: 18 | | string 19 | | { 20 | tKey: string; 21 | options: TOptions<$Dictionary>; 22 | } = '', 23 | prefix = '', 24 | ) { 25 | const translation = 26 | typeof tKey === 'string' 27 | ? t(tKey) 28 | : 'tKey' in tKey 29 | ? t(tKey.tKey, tKey.options) 30 | : t(tKey); 31 | const concatenatedPrefix = `${prefix} ${translation || ''}`.trim(); 32 | return concatenatedPrefix 33 | ? `${concatenatedPrefix} | ${t('app-name')}` 34 | : t('app-name'); 35 | } 36 | -------------------------------------------------------------------------------- /app/features/localization/i18next.server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { basename, dirname, join, resolve } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | import Backend from 'i18next-fs-backend'; 6 | import { RemixI18Next } from 'remix-i18next/server'; 7 | 8 | import { getRootDirectory } from '~/utils/get-root-directory'; 9 | 10 | import { i18n } from './i18n'; 11 | 12 | const currentDirectory = dirname(fileURLToPath(import.meta.url)); 13 | 14 | const localesDirectory = join( 15 | getRootDirectory(currentDirectory), 16 | 'public', 17 | 'locales', 18 | 'en', 19 | ); 20 | 21 | const ns: string[] = []; 22 | 23 | fs.readdirSync(localesDirectory).forEach(file => { 24 | const nsName = basename(file, '.json'); 25 | ns.push(nsName); 26 | }); 27 | 28 | export const i18next = new RemixI18Next({ 29 | detection: { 30 | supportedLanguages: i18n.supportedLngs, 31 | fallbackLanguage: i18n.fallbackLng, 32 | }, 33 | i18next: { 34 | ...i18n, 35 | backend: { 36 | loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'), 37 | }, 38 | ns, 39 | }, 40 | plugins: [Backend], 41 | }); 42 | -------------------------------------------------------------------------------- /app/features/onboarding/onboarding-loaders.server.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | import { json } from '@remix-run/node'; 3 | import { promiseHash } from 'remix-utils/promise'; 4 | 5 | import { getPageTitle } from '../localization/get-page-title.server'; 6 | import { i18next } from '../localization/i18next.server'; 7 | import { requireUserNeedsOnboarding } from './onboarding-helpers.server'; 8 | 9 | const onboardingLoader = 10 | (tkey: string) => 11 | async ({ request }: Pick) => { 12 | const { t, ...rest } = await promiseHash({ 13 | user: requireUserNeedsOnboarding(request), 14 | t: i18next.getFixedT(request), 15 | locale: i18next.getLocale(request), 16 | }); 17 | 18 | return json({ 19 | ...rest, 20 | t, 21 | pageTitle: getPageTitle(t, tkey, ''), 22 | }); 23 | }; 24 | 25 | export const onboardingUserProfileLoader = onboardingLoader( 26 | 'onboarding-user-profile:onboarding-user-profile', 27 | ); 28 | export const onboardingOrganizationLoader = onboardingLoader( 29 | 'onboarding-organization:onboarding-organization', 30 | ); 31 | -------------------------------------------------------------------------------- /app/test/i18n.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { basename, dirname, join } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | import i18next from 'i18next'; 6 | import { initReactI18next } from 'react-i18next'; 7 | 8 | import { getRootDirectory } from '~/utils/get-root-directory'; 9 | 10 | const currentDirectory = dirname(fileURLToPath(import.meta.url)); 11 | 12 | const localesDirectory = join( 13 | getRootDirectory(currentDirectory), 14 | 'public', 15 | 'locales', 16 | 'en', 17 | ); 18 | 19 | const ns: string[] = []; 20 | const resources: { [K: string]: string } = {}; 21 | 22 | fs.readdirSync(localesDirectory).forEach(file => { 23 | const nsName = basename(file, '.json'); 24 | // eslint-disable-next-line unicorn/prefer-module 25 | const nsData = require(join(localesDirectory, file)); 26 | 27 | ns.push(nsName); 28 | resources[nsName] = nsData; 29 | }); 30 | 31 | i18next.use(initReactI18next).init({ 32 | fallbackLng: 'en', 33 | lng: 'en', 34 | ns, 35 | resources: { 36 | en: resources, 37 | }, 38 | }); 39 | 40 | // eslint-disable-next-line unicorn/prefer-export-from 41 | export default i18next; 42 | -------------------------------------------------------------------------------- /app/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; 2 | import { CheckIcon } from 'lucide-react'; 3 | import * as React from 'react'; 4 | 5 | import { cn } from '~/utils/shadcn-ui'; 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )); 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 27 | 28 | export { Checkbox }; 29 | -------------------------------------------------------------------------------- /app/features/monitoring/monitoring-helpers.client.ts: -------------------------------------------------------------------------------- 1 | import { useLocation, useMatches } from '@remix-run/react'; 2 | import * as Sentry from '@sentry/remix'; 3 | import { useEffect } from 'react'; 4 | 5 | export function initializeClientMonitoring() { 6 | Sentry.init({ 7 | dsn: ENV.SENTRY_DSN, 8 | environment: ENV.ENVIRONMENT, 9 | integrations: [ 10 | Sentry.browserTracingIntegration({ 11 | useEffect, 12 | useLocation, 13 | useMatches, 14 | }), 15 | Sentry.replayIntegration(), // Replay is only available in the client. 16 | ], 17 | 18 | // Set tracesSampleRate to 1.0 to capture 100% of transactions for 19 | // performance monitoring. 20 | // We recommend adjusting this value in production. 21 | tracesSampleRate: 1, 22 | 23 | // Set `tracePropagationTargets` to control for which URLs distributed 24 | // tracing should be enabled. 25 | tracePropagationTargets: ['localhost', /^https:\/\/yourserver\.io\/api/], 26 | 27 | // Capture Replay for 10% of all sessions, plus for 100% of sessions with an 28 | // error. 29 | replaysSessionSampleRate: 0.1, 30 | replaysOnErrorSampleRate: 1, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /app/routes/organizations_.invite.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; 2 | import { useLoaderData } from '@remix-run/react'; 3 | 4 | import { AcceptMembershipInvitePageComponent } from '~/features/organizations/accept-membership-invite-page-component'; 5 | import { organizationsAcceptInviteAction } from '~/features/organizations/organizations-actions.server'; 6 | import { acceptInviteLinkPageLoader } from '~/features/organizations/organizations-loaders.server'; 7 | 8 | export const handle = { i18n: 'accept-membership-invite' }; 9 | 10 | export async function loader(loaderArguments: LoaderFunctionArgs) { 11 | return await acceptInviteLinkPageLoader(loaderArguments); 12 | } 13 | 14 | export const meta: MetaFunction = ({ data }) => [ 15 | { title: data?.pageTitle || 'Organization Invite' }, 16 | ]; 17 | 18 | export async function action(actionArguments: LoaderFunctionArgs) { 19 | return await organizationsAcceptInviteAction(actionArguments); 20 | } 21 | 22 | export default function OrganizationInvite() { 23 | const loaderData = useLoaderData(); 24 | return ; 25 | } 26 | -------------------------------------------------------------------------------- /app/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 84% 60%; 17 | --primary-foreground: 0 85.7% 97.3%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 84% 60%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | } 38 | 39 | @layer base { 40 | * { 41 | @apply border-border; 42 | } 43 | body { 44 | @apply bg-background text-foreground; 45 | } 46 | } 47 | 48 | @layer utilities { 49 | .popover-content-width-same-as-its-trigger { 50 | width: var(--radix-popover-trigger-width); 51 | max-height: var(--radix-popover-content-available-height); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/features/user-authentication/user-authentication-loaders.server.ts: -------------------------------------------------------------------------------- 1 | import type { Pick } from '@prisma/client/runtime/library'; 2 | import type { LoaderFunctionArgs } from '@remix-run/node'; 3 | import { json } from '@remix-run/node'; 4 | import { promiseHash } from 'remix-utils/promise'; 5 | 6 | import { getPageTitle } from '../localization/get-page-title.server'; 7 | import { i18next } from '../localization/i18next.server'; 8 | import { getInviteLinkToken } from '../organizations/organizations-helpers.server'; 9 | import { requireAnonymous } from './user-authentication-helpers.server'; 10 | 11 | const authenticationLoader = 12 | (tKey: string) => 13 | async ({ request }: Pick) => { 14 | const { t, ...rest } = await promiseHash({ 15 | request: requireAnonymous(request), 16 | t: i18next.getFixedT(request), 17 | locale: i18next.getLocale(request), 18 | }); 19 | 20 | return json({ 21 | ...rest, 22 | t, 23 | pageTitle: getPageTitle(t, tKey, ''), 24 | token: getInviteLinkToken(request) || '', 25 | }); 26 | }; 27 | 28 | export const loginLoader = authenticationLoader('login:login'); 29 | 30 | export const registerLoader = authenticationLoader('register:register'); 31 | -------------------------------------------------------------------------------- /app/features/monitoring/monitoring-helpers.server.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/remix'; 2 | 3 | export function initializeServerMonitoring() { 4 | Sentry.init({ 5 | beforeSendTransaction(event) { 6 | // ignore all healthcheck related transactions 7 | // note that name of header here is case-sensitive 8 | if (event.request?.headers?.['x-healthcheck'] === 'true') { 9 | // eslint-disable-next-line unicorn/no-null 10 | return null; 11 | } 12 | 13 | return event; 14 | }, 15 | denyUrls: [ 16 | /\/healthcheck/, 17 | // TODO: be smarter about the public assets... 18 | /\/build\//, 19 | /\/favicons\//, 20 | /\/img\//, 21 | /\/fonts\//, 22 | /\/favicon.ico/, 23 | /\/site\.webmanifest/, 24 | ], 25 | dsn: process.env.SENTRY_DSN, 26 | environment: process.env.NODE_ENV, 27 | integrations: [Sentry.prismaIntegration()], 28 | tracesSampler(samplingContext) { 29 | // ignore healthcheck transactions by other services (consul, etc.) 30 | if (samplingContext.request?.url?.includes('/healthcheck')) { 31 | return 0; 32 | } 33 | return 1; 34 | }, 35 | tracesSampleRate: process.env.NODE_ENV === 'production' ? 1 : 0, 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /app/features/localization/use-translation.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import { I18nextProvider } from 'react-i18next'; 3 | import { describe, expect, test } from 'vitest'; 4 | 5 | import i18n from '~/test/i18n'; 6 | 7 | import { useTranslation } from './use-translation'; 8 | 9 | describe('useTranslation()', () => { 10 | test('given a key for a translation that exists: returns the correct translation', () => { 11 | const { result } = renderHook(() => useTranslation('login'), { 12 | wrapper: ({ children }) => ( 13 | {children} 14 | ), 15 | }); 16 | 17 | const actual = result.current.t('login'); 18 | const expected = 'Login'; 19 | 20 | expect(actual).toEqual(expected); 21 | }); 22 | 23 | test('given a key for a translation that does NOT exist: returns the key (instead of null)', () => { 24 | const { result } = renderHook(() => useTranslation('login'), { 25 | wrapper: ({ children }) => ( 26 | {children} 27 | ), 28 | }); 29 | 30 | const actual = result.current.t('does-not-exist'); 31 | const expected = 'does-not-exist'; 32 | 33 | expect(actual).toEqual(expected); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /app/hooks/use-promise.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | import { usePromise } from './use-promise'; 5 | 6 | const getPromiseState = (p: Promise) => { 7 | const t = {}; 8 | return Promise.race([p, t]).then( 9 | v => (v === t ? 'pending' : 'fulfilled'), 10 | () => 'rejected', 11 | ); 12 | }; 13 | 14 | describe('usePromise()', () => { 15 | it('handles resolving promises', async () => { 16 | const { result } = renderHook(() => usePromise()); 17 | 18 | expect(await getPromiseState(result.current[0])).toEqual('pending'); 19 | 20 | const testValue = { foo: 'bar' }; 21 | 22 | act(() => { 23 | result.current[1](testValue); 24 | }); 25 | 26 | expect(await getPromiseState(result.current[0])).toEqual('fulfilled'); 27 | expect(await result.current[0]).toEqual(testValue); 28 | }); 29 | 30 | it('handles rejecting promises', async () => { 31 | const { result } = renderHook(() => usePromise()); 32 | 33 | const testValue = { foo: 'bar' }; 34 | 35 | act(() => { 36 | result.current[2](testValue); 37 | }); 38 | 39 | expect(await getPromiseState(result.current[0])).toEqual('rejected'); 40 | expect(await result.current[0].catch(error => error)).toEqual(testValue); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /app/features/localization/use-translation.ts: -------------------------------------------------------------------------------- 1 | import { useTranslation as useTranslationI18Next } from 'react-i18next'; 2 | 3 | type UseTranslationType = typeof useTranslationI18Next; 4 | 5 | /** 6 | * A custom hook that wraps the `useTranslation` hook from `react-i18next`. 7 | * This hook is designed to ensure that if a translation key does not exist, 8 | * an empty string is returned instead of `null`. It helps to avoid rendering 9 | * `null` values in the UI when a translation is missing. 10 | * 11 | * @param params - The parameters that you would normally pass to 12 | * `useTranslation` from `react-i18next`. This can include namespaces or other 13 | * options. 14 | * 15 | * @returns An object containing: 16 | * - `t`: A translation function that behaves like the original `t` function, 17 | * but returns an empty string for missing translations. 18 | * - Rest of the properties returned by React i18next's `useTranslation`. 19 | * 20 | * @example 21 | * const { t } = useTranslation(); 22 | * console.log(t('missing.key')); // Outputs: '' 23 | */ 24 | export const useTranslation = (...params: Parameters) => { 25 | const { t, ...rest } = useTranslationI18Next(...params); 26 | 27 | const translate = (...arguments_: Parameters): string => 28 | t(...arguments_) || ''; 29 | 30 | return { t: translate, ...rest }; 31 | }; 32 | -------------------------------------------------------------------------------- /app/routes/verify-complete.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | import { useLoaderData } from '@remix-run/react'; 3 | 4 | import { Text } from '~/components/text'; 5 | import { useTranslation } from '~/features/localization/use-translation'; 6 | import { getSearchParameterFromRequest } from '~/utils/get-search-parameter-from-request.server'; 7 | 8 | export const loader = async ({ request }: LoaderFunctionArgs) => { 9 | return { 10 | status: getSearchParameterFromRequest('__clerk_status')(request), 11 | sessions: getSearchParameterFromRequest('__clerk_session')(request), 12 | action: getSearchParameterFromRequest('action')(request), 13 | }; 14 | }; 15 | 16 | export const handle = { i18n: ['register', 'login'] }; 17 | 18 | export default function VerifyComplete() { 19 | const { t } = useTranslation(); 20 | const { status, action } = useLoaderData(); 21 | 22 | return ( 23 |
24 |
25 | 26 | {status === 'verified' 27 | ? action === 'login' 28 | ? t('login:email-verified-description') 29 | : t('register:email-verified-description') 30 | : t('register:email-verification-failed-description')} 31 | 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /playwright/e2e/user-authentication/logout.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | import { teardownOrganizationAndMember } from '~/test/test-utils'; 4 | 5 | import { setupOrganizationAndLoginAsMember } from '../../utils'; 6 | 7 | test.describe('logout', () => { 8 | test('given an onboarded user on a page where the sidebar is visible: lets the user log out', async ({ 9 | page, 10 | browserName, 11 | }) => { 12 | // eslint-disable-next-line playwright/no-skipped-test 13 | test.skip( 14 | browserName === 'webkit', 15 | 'Safari (Desktop & Mobile) fails in CI. Locally it works ...', 16 | ); 17 | 18 | const { organization, user } = await setupOrganizationAndLoginAsMember({ 19 | page, 20 | }); 21 | 22 | // Navigate to a page with a sidebar, e.g. the organization's home page. 23 | await page.goto(`/organizations/${organization.slug}/home`); 24 | 25 | // Open the user menu. 26 | await page.getByRole('button', { name: /open user menu/i }).click(); 27 | 28 | // Click the logout button. 29 | await page.getByRole('button', { name: /log out/i }).click(); 30 | await expect( 31 | page.getByRole('heading', { name: /french house stack/i, level: 1 }), 32 | ).toBeVisible(); 33 | 34 | await page.close(); 35 | await teardownOrganizationAndMember({ organization, user }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /app/components/ui/typography.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react'; 2 | 3 | import { cn } from '~/utils/shadcn-ui'; 4 | 5 | export function TypographyH1({ 6 | className, 7 | ...props 8 | }: ComponentPropsWithoutRef<'h1'>) { 9 | return ( 10 |

17 | ); 18 | } 19 | 20 | export function TypographyH2({ 21 | className, 22 | ...props 23 | }: ComponentPropsWithoutRef<'h2'>) { 24 | return ( 25 |

32 | ); 33 | } 34 | 35 | export function TypographyH3({ 36 | className, 37 | ...props 38 | }: ComponentPropsWithoutRef<'h3'>) { 39 | return ( 40 |

47 | ); 48 | } 49 | 50 | export function TypographyP({ 51 | className, 52 | ...props 53 | }: ComponentPropsWithoutRef<'p'>) { 54 | return ( 55 |

59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /public/locales/en/settings-account.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "Cancel", 3 | "current-owned-organizations_one": "Your account is currently an owner in this organization: <1>{{organizations}}.", 4 | "current-owned-organizations_other": "Your account is currently an owner in these organizations: <1>{{organizations}}.", 5 | "delete-account": "Delete account", 6 | "delete-warning": "Once you delete your account, there is no going back. Please be certain.", 7 | "delete-your-account": "Delete your account", 8 | "deleting": "Deleting ...", 9 | "description": "Manage your account settings.", 10 | "dialog-description": "This action cannot be undone. This will permanently delete your account and remove your data from our servers.", 11 | "dialog-title": "Are you absolutely sure?", 12 | "email-address": "Email address", 13 | "email-description": "You can't change your email address. If you need to change it, please contact support.", 14 | "email-placeholder": "Your email address ...", 15 | "still-an-owner": "You are still an owner of at least one organization and cannot delete your account.", 16 | "title": "Account", 17 | "unlock-deletion-description_one": "You must remove yourself, transfer ownership, or delete this organization before you can delete your user.", 18 | "unlock-deletion-description_other": "You must remove yourself, transfer ownership, or delete these organizations before you can delete your user." 19 | } -------------------------------------------------------------------------------- /app/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as PopoverPrimitive from '@radix-ui/react-popover'; 2 | import * as React from 'react'; 3 | 4 | import { cn } from '~/utils/shadcn-ui'; 5 | 6 | const Popover = PopoverPrimitive.Root; 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger; 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor; 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )); 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName; 30 | 31 | export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }; 32 | -------------------------------------------------------------------------------- /public/locales/en/organization-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "Cancel", 3 | "danger-zone": "Danger zone", 4 | "delete-organization": "Delete organization", 5 | "delete-this-organization": "Delete this organization", 6 | "deleting": "Deleting ...", 7 | "deletion-warning": "Once deleted, it will be gone forever. Please be certain.", 8 | "description": "General settings for this organization.", 9 | "dialog-description": "This action cannot be undone. This will permanently delete this organization, remove its data from our servers and every member looses their membership.", 10 | "dialog-title": "Are you absolutely sure?", 11 | "general": "General", 12 | "name-max-length": "Your organization name must be at most 255 characters long.", 13 | "name-min-length": "Your organization name must be at least 3 characters long.", 14 | "name-must-be-string": "Your organization's name must be a string.", 15 | "organization-deleted": "Organization has been deleted", 16 | "organization-name-change-warning": "<1>Warning: Changing your organization's name will break all existing links to your organization.", 17 | "organization-name-description": "Your organization's public display name.", 18 | "organization-name-label": "Organization name", 19 | "organization-name-placeholder": "Your organization's name ...", 20 | "organization-profile-updated": "Organization has been updated", 21 | "save": "Save", 22 | "saving": "Saving ...", 23 | "title": "General" 24 | } -------------------------------------------------------------------------------- /app/components/disableable-link.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { createRemixStub, render, screen } from '~/test/react-test-utils'; 4 | import type { Factory } from '~/utils/types'; 5 | 6 | import type { DisableableLinkComponentProps } from './disableable-link'; 7 | import { DisableableLink } from './disableable-link'; 8 | 9 | const createProps: Factory = ({ 10 | children = 'Click Me', 11 | disabled = false, 12 | to = '/test', 13 | ...rest 14 | } = {}) => ({ children, disabled, to, ...rest }); 15 | 16 | describe('DisableableLink component', () => { 17 | test('given the link is enabled: renders a link', () => { 18 | const props = createProps(); 19 | const RemixStub = createRemixStub([ 20 | { path: '/', Component: () => }, 21 | ]); 22 | 23 | render(); 24 | 25 | expect(screen.getByRole('link', { name: /click me/i })).toHaveAttribute( 26 | 'href', 27 | props.to, 28 | ); 29 | }); 30 | 31 | test('given the link is enabled: does NOT render a link', () => { 32 | const props = createProps({ disabled: true }); 33 | const RemixStub = createRemixStub([ 34 | { path: '/', Component: () => }, 35 | ]); 36 | 37 | render(); 38 | 39 | expect( 40 | screen.queryByRole('link', { name: /click me/i }), 41 | ).not.toBeInTheDocument(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /app/components/text.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { createRemixStub, render, screen } from '~/test/react-test-utils'; 4 | 5 | import { Code, Strong, Text, TextLink } from './text'; 6 | 7 | describe('Text component', () => { 8 | test('given text as children: renders the text', () => { 9 | render(Sample Text); 10 | 11 | expect(screen.getByText('Sample Text')).toBeInTheDocument(); 12 | }); 13 | }); 14 | 15 | describe('TextLink component', () => { 16 | test('given text as children and a link: renders the text and has the correct href', () => { 17 | const href = '/test-link'; 18 | const RemixStub = createRemixStub([ 19 | { path: '/', Component: () => Link Text }, 20 | ]); 21 | 22 | render(); 23 | 24 | expect(screen.getByRole('link', { name: 'Link Text' })).toHaveAttribute( 25 | 'href', 26 | href, 27 | ); 28 | }); 29 | }); 30 | 31 | describe('Text components', () => { 32 | test('given text as children: renders the text with the correct class', () => { 33 | render(Strong Text); 34 | 35 | expect(screen.getByText('Strong Text')).toHaveClass('font-medium'); 36 | }); 37 | }); 38 | 39 | describe('Text components', () => { 40 | test('given text as children: renders the text with the correct class', () => { 41 | render(Code Snippet); 42 | 43 | expect(screen.getByText('Code Snippet')).toHaveClass('font-mono'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /app/components/text.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react'; 2 | import type { ComponentPropsWithoutRef } from 'react'; 3 | 4 | import { cn } from '~/utils/shadcn-ui'; 5 | 6 | export function Text({ className, ...props }: ComponentPropsWithoutRef<'p'>) { 7 | return ( 8 |

12 | ); 13 | } 14 | 15 | export function TextLink({ 16 | className, 17 | ...props 18 | }: ComponentPropsWithoutRef) { 19 | return ( 20 | // eslint-disable-next-line jsx-a11y/anchor-has-content 21 | 28 | ); 29 | } 30 | 31 | export function Strong({ 32 | className, 33 | ...props 34 | }: ComponentPropsWithoutRef<'strong'>) { 35 | return ( 36 | 40 | ); 41 | } 42 | 43 | export function Code({ 44 | className, 45 | ...props 46 | }: ComponentPropsWithoutRef<'code'>) { 47 | return ( 48 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /public/locales/en/organization-team-members.json: -------------------------------------------------------------------------------- 1 | { 2 | "admin": "Admin", 3 | "admin-description": "Can edit the organization and manage billing.", 4 | "card-description-invite-link": "Invite team members to your organization. You can generate an invite link here. After generating the link, it will be valid for 48 hours. Simply send it to your team members and they will be able to join your organization by clicking it.", 5 | "card-title-invite-link": "Invite team members", 6 | "card-title-members-list": "Members", 7 | "copied": "Copied!", 8 | "copy-invite-link": "Copy invite link to clipboard", 9 | "create-new-invite-link": "Create new invite link", 10 | "creating": "Creating ...", 11 | "deactivate-link": "Deactivate link", 12 | "deactivated": "Deactivated", 13 | "deactivated-description": "Access revoked to everything.", 14 | "deactivating": "Deactivating ...", 15 | "go-to-link": "Go to the invite link's page", 16 | "invite-link-copied": "Invite link copied to clipboard", 17 | "link-valid-until": "Your link is valid until {{date}}.", 18 | "member": "Member", 19 | "member-description": "Access to standard features.", 20 | "new-link-deactivates-old": "Generating a new link automatically deactivates the old one.", 21 | "no-roles-found": "No roles found.", 22 | "owner": "Owner", 23 | "owner-description": "Can assign roles and delete the organization.", 24 | "regenerate-link": "Regenerate link", 25 | "regenerating": "Regenerating ...", 26 | "roles-placeholder": "Select new role ...", 27 | "team-members": "Team members" 28 | } -------------------------------------------------------------------------------- /templates/playwright/e2e/feature/feature.spec.hbs: -------------------------------------------------------------------------------- 1 | import AxeBuilder from '@axe-core/playwright'; 2 | import { expect, test } from '@playwright/test'; 3 | 4 | import { deleteUserProfileFromDatabaseById } from '~/features/user-profile/user-profile-model.server'; 5 | 6 | import { loginAndSaveUserProfileToDatabase } from '../../utils'; 7 | 8 | test.describe('{{feature}} page', () => { 9 | test("given the user is logged out: page redirects you to the login page and remembers the page as the redirectTo query parameter", async ({ 10 | page, 11 | baseURL, 12 | }) => { 13 | await page.goto('.{{name}}'); 14 | const expectedUrl = new URL(baseURL + '/login'); 15 | expectedUrl.searchParams.append('redirectTo', '{{name}}'); 16 | expect(page.url()).toEqual(expectedUrl.href); 17 | }); 18 | 19 | test('page has the correct title', async ({ page }) => { 20 | await page.goto('.{{name}}'); 21 | expect(await page.title()).toEqual('{{titleCase feature}}'); 22 | await expect(page.getByRole('heading', { level: 1, name: '{{titleCase feature}}' })).toBeVisible(); 23 | }); 24 | 25 | test('page should not have any automatically detectable accessibility issues', async ({ 26 | page, 27 | }) => { 28 | const { id } = await loginAndSaveUserProfileToDatabase({ page }); 29 | await page.goto('.{{name}}'); 30 | 31 | const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); 32 | 33 | expect(accessibilityScanResults.violations).toEqual([]); 34 | 35 | await deleteUserProfileFromDatabaseById(id); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /app/features/user-authentication/user-auth-session-factories.server.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { createId } from '@paralleldrive/cuid2'; 3 | import type { UserAuthSession } from '@prisma/client'; 4 | 5 | import { generateRandomClerkId } from '~/test/generate-random-did.server'; 6 | import type { Factory } from '~/utils/types'; 7 | 8 | /** 9 | * Creates a user auth session _**without**_ any values. If you want to create a 10 | * user auth session with values, use `createPopulatedUserAuthSession` instead. 11 | * 12 | * @param sessionParams - User auth session params to create session with. 13 | * @returns User auth session with given params. 14 | */ 15 | export const createUserAuthSession: Factory = ({ 16 | id = '', 17 | createdAt = new Date(), 18 | updatedAt = new Date(), 19 | userId = '', 20 | expirationDate = new Date(), 21 | } = {}) => ({ id, userId, createdAt, updatedAt, expirationDate }); 22 | 23 | /** 24 | * Creates a user auth session with populated values. 25 | * 26 | * @param sessionParams - User auth session params to create session with. 27 | * @returns A populated user auth session with given params. 28 | */ 29 | export const createPopulatedUserAuthSession: Factory = ({ 30 | id = createId(), 31 | updatedAt = faker.date.recent({ days: 10 }), 32 | createdAt = faker.date.past({ years: 3, refDate: updatedAt }), 33 | userId = generateRandomClerkId(), 34 | expirationDate = faker.date.future({ years: 1, refDate: updatedAt }), 35 | } = {}) => ({ id, userId, createdAt, updatedAt, expirationDate }); 36 | -------------------------------------------------------------------------------- /app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as AvatarPrimitive from '@radix-ui/react-avatar'; 2 | import * as React from 'react'; 3 | 4 | import { cn } from '~/utils/shadcn-ui'; 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )); 19 | Avatar.displayName = AvatarPrimitive.Root.displayName; 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )); 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )); 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 47 | 48 | export { Avatar, AvatarFallback, AvatarImage }; 49 | -------------------------------------------------------------------------------- /public/locales/en/login.json: -------------------------------------------------------------------------------- 1 | { 2 | "authenticating": "Authenticating ...", 3 | "create-your-account": "Create your account.", 4 | "session-id-malformed-error": "A Session Id must be string.", 5 | "session-id-missing": "Login failed. No session id", 6 | "email-address": "Email address", 7 | "email-description": "Your account's email address.", 8 | "email-invalid": "A valid email consists of characters, '@' and '.'.", 9 | "email-must-be-string": "A valid email address must be string.", 10 | "email-placeholder": "Your email address ...", 11 | "email-required": "Please enter a valid email (required).", 12 | "failed-to-load-magic": "Failed to load authentication provider https://magic.link. Please reload the page to try again.", 13 | "log-in-to-your-account": "Log in to your account", 14 | "login": "Login", 15 | "login-failed": "Login failed. Please try again.", 16 | "missing-email-metadata": "Missing email from Magic metadata.", 17 | "missing-issuer-metadata": "Missing issuer from Magic metadata.", 18 | "not-a-member": "Not a member?", 19 | "user-doesnt-exist": "User with given email doesn't exist. Did you mean to create a new account instead?", 20 | "verify-email": "Verify email", 21 | "verify-email-description": "A login link has been sent to <1>{{email}}. Please click the link in the email to login your account.", 22 | "back-to-login": "Go back to login", 23 | "email-verified-description": "Email verification successful. Close this tab and return to the login page to continue.", 24 | "email-verification-failed-description": "Email verification failed. Token is invalid or expired. Please try again." 25 | } 26 | -------------------------------------------------------------------------------- /app/routes/organizations_.$organizationSlug.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | import type { ShouldRevalidateFunctionArgs } from '@remix-run/react'; 3 | import { useLoaderData } from '@remix-run/react'; 4 | 5 | import { organizationSlugLoader } from '~/features/organizations/organizations-loaders.server'; 6 | import { OrganizationsSidebarComponent } from '~/features/organizations/organizations-sidebar-component'; 7 | 8 | export const handle = { i18n: ['organizations', 'sidebar', 'header'] }; 9 | 10 | /* 11 | With single fetch enabled, the layout route loader will always be called for every subroute change. 12 | we can skip re-running this loader if the organization slug remains unchanged by opting into Remix's granular single fetch. 13 | If you want to opt out of granular single fetch and always re-run this loader, you can remove this function 14 | To learn more about granular single fetch, see the Remix documentation on revalidations: 15 | https://remix.run/docs/en/main/guides/single-fetch#revalidations 16 | **/ 17 | export const shouldRevalidate = ({ 18 | currentParams, 19 | nextParams, 20 | defaultShouldRevalidate, 21 | }: ShouldRevalidateFunctionArgs) => { 22 | if (currentParams.organizationSlug !== nextParams.organizationSlug) { 23 | return true; 24 | } 25 | return defaultShouldRevalidate; 26 | }; 27 | 28 | export async function loader({ request, params }: LoaderFunctionArgs) { 29 | return await organizationSlugLoader({ request, params }); 30 | } 31 | 32 | export default function Organization() { 33 | const data = useLoaderData(); 34 | 35 | return ; 36 | } 37 | -------------------------------------------------------------------------------- /playwright/e2e/settings/settings.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | import { deleteUserProfileFromDatabaseById } from '~/features/user-profile/user-profile-model.server'; 4 | import { teardownOrganizationAndMember } from '~/test/test-utils'; 5 | 6 | import { 7 | getPath, 8 | loginAndSaveUserProfileToDatabase, 9 | setupOrganizationAndLoginAsMember, 10 | } from '../../utils'; 11 | 12 | test.describe('settings root page', () => { 13 | test('given a logged out user: redirects the user to the login page', async ({ 14 | page, 15 | }) => { 16 | await page.goto('/settings'); 17 | const searchParams = new URLSearchParams(); 18 | searchParams.append('redirectTo', '/settings/profile'); 19 | expect(getPath(page)).toEqual(`/login?${searchParams.toString()}`); 20 | }); 21 | 22 | test('given a logged in user who is NOT onboarded: redirects the user to the onboarding page', async ({ 23 | page, 24 | }) => { 25 | const user = await loginAndSaveUserProfileToDatabase({ name: '', page }); 26 | 27 | await page.goto('/settings'); 28 | expect(getPath(page)).toEqual('/onboarding/user-profile'); 29 | 30 | await deleteUserProfileFromDatabaseById(user.id); 31 | }); 32 | 33 | test('given a logged in user who is onboarded: redirects the user to the user profile page and renders a nav bar', async ({ 34 | page, 35 | }) => { 36 | const { organization, user } = await setupOrganizationAndLoginAsMember({ 37 | page, 38 | }); 39 | 40 | await page.goto('/settings'); 41 | expect(getPath(page)).toEqual('/settings/profile'); 42 | 43 | await teardownOrganizationAndMember({ organization, user }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /app/features/user-authentication/user-authentication-session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, type Session } from '@remix-run/node'; 2 | import invariant from 'tiny-invariant'; 3 | 4 | import { asyncPipe } from '~/utils/async-pipe'; 5 | 6 | invariant(process.env.SESSION_SECRET, 'SESSION_SECRET must be set'); 7 | 8 | const USER_AUTH_SESSION_KEY = 'userAuthSessionId'; 9 | export const USER_AUTHENTICATION_SESSION_NAME = '__user-authentication-session'; 10 | export const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365; 11 | 12 | export const userAuthenticationSessionStorage = createCookieSessionStorage({ 13 | cookie: { 14 | httpOnly: true, 15 | maxAge: 0, 16 | name: USER_AUTHENTICATION_SESSION_NAME, 17 | path: '/', 18 | sameSite: 'lax', 19 | secrets: [process.env.SESSION_SECRET], 20 | secure: process.env.NODE_ENV === 'production', 21 | }, 22 | }); 23 | 24 | const getCookie = (request: Request) => request.headers.get('Cookie'); 25 | 26 | const getSessionFromCookie = (cookie: string | null) => 27 | userAuthenticationSessionStorage.getSession(cookie); 28 | 29 | export const getSession = asyncPipe(getCookie, getSessionFromCookie); 30 | 31 | export const getUserAuthSessionId = (session: Session): string | undefined => 32 | session.get(USER_AUTH_SESSION_KEY); 33 | 34 | export async function createCookieForUserAuthSession({ 35 | request, 36 | userAuthSessionId, 37 | }: { 38 | request: Request; 39 | userAuthSessionId: string; 40 | }) { 41 | const session = await getSession(request); 42 | session.set(USER_AUTH_SESSION_KEY, userAuthSessionId); 43 | return userAuthenticationSessionStorage.commitSession(session, { 44 | maxAge: ONE_YEAR_IN_SECONDS, 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /playwright/e2e/organizations/organizations.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | import { deleteUserProfileFromDatabaseById } from '~/features/user-profile/user-profile-model.server'; 4 | import { teardownOrganizationAndMember } from '~/test/test-utils'; 5 | 6 | import { 7 | getPath, 8 | loginAndSaveUserProfileToDatabase, 9 | setupOrganizationAndLoginAsMember, 10 | } from '../../utils'; 11 | 12 | test.describe('organizations root page', () => { 13 | test('given a logged out user: redirects the user to the login page', async ({ 14 | page, 15 | }) => { 16 | await page.goto('/organizations'); 17 | const searchParams = new URLSearchParams(); 18 | searchParams.append('redirectTo', '/organizations'); 19 | expect(getPath(page)).toEqual(`/login?${searchParams.toString()}`); 20 | }); 21 | 22 | test('given a logged in user who is NOT onboarded: redirects the user to the onboarding page', async ({ 23 | page, 24 | }) => { 25 | const user = await loginAndSaveUserProfileToDatabase({ name: '', page }); 26 | 27 | await page.goto('/organizations'); 28 | expect(getPath(page)).toEqual('/onboarding/user-profile'); 29 | 30 | await deleteUserProfileFromDatabaseById(user.id); 31 | }); 32 | 33 | test('given a logged in user who is onboarded: redirects the user to the home page of their first organization', async ({ 34 | page, 35 | }) => { 36 | const { organization, user } = await setupOrganizationAndLoginAsMember({ 37 | page, 38 | }); 39 | 40 | await page.goto('/organizations'); 41 | expect(getPath(page)).toEqual(`/organizations/${organization.slug}/home`); 42 | 43 | await teardownOrganizationAndMember({ organization, user }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /app/utils/get-error-message.ts: -------------------------------------------------------------------------------- 1 | type ErrorWithMessage = { 2 | message: string; 3 | }; 4 | 5 | function isErrorWithMessage(error: unknown): error is ErrorWithMessage { 6 | return ( 7 | typeof error === 'object' && 8 | error !== null && 9 | 'message' in error && 10 | typeof (error as Record).message === 'string' 11 | ); 12 | } 13 | 14 | function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { 15 | if (isErrorWithMessage(maybeError)) return maybeError; 16 | 17 | try { 18 | if (typeof maybeError === 'string') return new Error(maybeError); 19 | 20 | return new Error(JSON.stringify(maybeError)); 21 | } catch { 22 | // fallback in case there's an error stringifying the maybeError 23 | // like with circular references for example. 24 | return new Error(String(maybeError)); 25 | } 26 | } 27 | 28 | /** 29 | * Get the error message from an error or any other thing that has been thrown. 30 | * 31 | * @param error - Something that has been thrown and might be an error. 32 | * @returns A string containing the error message. 33 | * 34 | * @example 35 | * 36 | * Used on an Error instance: 37 | * 38 | * ```ts 39 | * getErrorMessage(new Error('Something went wrong')) 40 | * // ↵ 'Something went wrong' 41 | * ``` 42 | * 43 | * Used on a non-error object: 44 | * 45 | * ```ts 46 | * getErrorMessage({ message: 'Something went wrong' }) 47 | * // ↵ 'Something went wrong' 48 | * ``` 49 | * 50 | * Used on a non-error object with no message property (e.g. a primitive): 51 | * 52 | * ```ts 53 | * getErrorMessage('Something went wrong') 54 | * // ↵ '"some-string"' 55 | * ``` 56 | */ 57 | export function getErrorMessage(error: unknown) { 58 | return toErrorWithMessage(error).message; 59 | } 60 | -------------------------------------------------------------------------------- /app/features/user-profile/user-profile-factories.server.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { createId } from '@paralleldrive/cuid2'; 3 | import type { UserProfile } from '@prisma/client'; 4 | 5 | import { generateRandomClerkId } from '~/test/generate-random-did.server'; 6 | import type { Factory } from '~/utils/types'; 7 | 8 | /** 9 | * Creates a user profile _**without**_ any values. If you want to create a 10 | * user profile with values, use `createPopulatedUserProfile` instead. 11 | * 12 | * @param userProfileParams - User profile params to create user profile with. 13 | * @returns User profile with given params. 14 | */ 15 | export const createUserProfile: Factory = ({ 16 | id = '', 17 | createdAt = new Date(), 18 | updatedAt = new Date(), 19 | clerkId = '', 20 | email = '', 21 | name = '', 22 | acceptedTermsAndConditions = false, 23 | } = {}) => ({ 24 | id, 25 | clerkId, 26 | email, 27 | name, 28 | createdAt, 29 | updatedAt, 30 | acceptedTermsAndConditions, 31 | }); 32 | 33 | /** 34 | * Creates a user profile with populated values. 35 | * 36 | * @param userProfileParams - User profile params to create user profile with. 37 | * @returns A populated user profile with given params. 38 | */ 39 | export const createPopulatedUserProfile: Factory = ({ 40 | id = createId(), 41 | clerkId = generateRandomClerkId(), 42 | email = faker.internet.email(), 43 | name = faker.person.fullName(), 44 | updatedAt = faker.date.recent({ days: 10 }), 45 | createdAt = faker.date.past({ years: 3, refDate: updatedAt }), 46 | acceptedTermsAndConditions = true, 47 | } = {}) => ({ 48 | id, 49 | clerkId, 50 | email, 51 | name, 52 | createdAt, 53 | updatedAt, 54 | acceptedTermsAndConditions, 55 | }); 56 | -------------------------------------------------------------------------------- /app/features/settings/settings-helpers.server.ts: -------------------------------------------------------------------------------- 1 | import type { HeaderUserProfileDropDownProps } from '~/components/header'; 2 | 3 | import type { OnboardingUser } from '../onboarding/onboarding-helpers.server'; 4 | import { ORGANIZATION_MEMBERSHIP_ROLES } from '../organizations/organizations-constants'; 5 | import { getNameAbbreviation } from '../user-profile/user-profile-helpers.server'; 6 | 7 | /** 8 | * Transforms user data into the props for the settings page. 9 | * 10 | * @param {Data} param - An object containing at least a `user` field of 11 | * the `OnboardingUser` type. 12 | * @param {OnboardingUser} param.user - The user data to be transformed. 13 | * @param rest - Additional properties that will be included in the returned 14 | * object as-is. 15 | * @returns An object with `userNavigation` for the header user profile 16 | * dropdown, including the original `user` object and any other properties. 17 | */ 18 | export const mapUserDataToSettingsProps = < 19 | Data extends { 20 | user: OnboardingUser; 21 | }, 22 | >({ 23 | user, 24 | ...rest 25 | }: Data) => ({ 26 | userNavigation: { 27 | abbreviation: getNameAbbreviation(user.name), 28 | email: user.email, 29 | name: user.name, 30 | items: [] as HeaderUserProfileDropDownProps['items'], 31 | }, 32 | user, 33 | ...rest, 34 | }); 35 | 36 | /** 37 | * Filters out the organizations that the user is an owner of. 38 | * 39 | * @param {OnboardingUser} user - The user to check for ownership status. 40 | * @returns An array of organizations that the user is an owner of. 41 | */ 42 | export const getUsersOwnedOrganizations = (user: OnboardingUser) => 43 | user.memberships 44 | .filter(({ role }) => role === ORGANIZATION_MEMBERSHIP_ROLES.OWNER) 45 | .map(({ organization }) => organization); 46 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from '@remix-run/dev'; 2 | import react from '@vitejs/plugin-react'; 3 | import { defineConfig } from 'vite'; 4 | import tsconfigPaths from 'vite-tsconfig-paths'; 5 | 6 | export default defineConfig(({ isSsrBuild }) => ({ 7 | build: isSsrBuild ? { target: 'ES2022' } : {}, 8 | plugins: [ 9 | process.env.VITEST 10 | ? react() 11 | : remix({ 12 | ignoredRouteFiles: ['**/.*', '*/*.test.ts', '*/*.test.tsx'], 13 | future: { 14 | v3_fetcherPersist: true, 15 | v3_relativeSplatPath: true, 16 | v3_throwAbortReason: true, 17 | unstable_singleFetch: true, 18 | unstable_optimizeDeps: true, 19 | unstable_lazyRouteDiscovery: true, 20 | }, 21 | }), 22 | tsconfigPaths(), 23 | ], 24 | server: { 25 | port: 3000, 26 | }, 27 | ssr: { 28 | // See: https://remix.run/docs/en/main/guides/gotchas#importing-esm-packages 29 | // See: https://remix.run/docs/en/main/future/vite#esm--cjs 30 | noExternal: ['@magic-sdk/admin', 'msw', 'path-to-regexp'], 31 | }, 32 | test: { 33 | environment: 'happy-dom', 34 | environmentMatchGlobs: [ 35 | ['**/*.server.{spec,test}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', 'node'], 36 | ['app/routes/**/*.test.ts', 'node'], 37 | ], 38 | globals: true, 39 | setupFiles: ['./app/test/setup-test-environment.ts'], 40 | include: ['./app/**/*.{spec,test}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 41 | watchExclude: [ 42 | String.raw`.*\/node_modules\/.*`, 43 | String.raw`.*\/build\/.*`, 44 | String.raw`.*\/postgres-data\/.*`, 45 | ], 46 | coverage: { 47 | reporter: ['text', 'json', 'html'], 48 | }, 49 | retry: process.env.CI ? 5 : 0, 50 | }, 51 | })); 52 | -------------------------------------------------------------------------------- /app/utils/pagination.server.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { getSearchParameterFromRequest } from './get-search-parameter-from-request.server'; 4 | 5 | const getPageFromRequest = getSearchParameterFromRequest('page'); 6 | 7 | /** 8 | * Returns an always valid page number from the request query parameters. 9 | * 10 | * @param params - The request and the total number of items. 11 | * @returns A valid page number to query from. 12 | */ 13 | export function getValidPageFromRequest({ 14 | request, 15 | totalItemCount, 16 | perPage = 10, 17 | }: { 18 | request: Request; 19 | totalItemCount: number; 20 | perPage?: number; 21 | }) { 22 | if (totalItemCount === 0) { 23 | return 1; 24 | } 25 | 26 | const page = getPageFromRequest(request); 27 | const totalPages = Math.ceil(totalItemCount / perPage); 28 | 29 | const pageSchema = z.preprocess( 30 | Number, 31 | z.number().int().positive().max(totalPages), 32 | ); 33 | const result = pageSchema.safeParse(page); 34 | 35 | if (result.success) { 36 | return result.data; 37 | } 38 | 39 | if (result.error.issues[0].code === 'too_big') { 40 | return totalPages; 41 | } 42 | 43 | return 1; 44 | } 45 | 46 | /** 47 | * A middleware that adds a `currentPage` property to the middleware object 48 | * based on the `page` request query parameter and the total number of items. 49 | * 50 | * @param middleware - The middleware object. 51 | * @returns The middleware object with the `currentPage` property. 52 | */ 53 | export const withCurrentPage = < 54 | T extends { request: Request; totalItemCount: number }, 55 | >({ 56 | request, 57 | totalItemCount, 58 | ...rest 59 | }: T) => ({ 60 | request, 61 | totalItemCount, 62 | currentPage: getValidPageFromRequest({ request, totalItemCount }), 63 | ...rest, 64 | }); 65 | -------------------------------------------------------------------------------- /app/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; 2 | import * as React from 'react'; 3 | 4 | import { cn } from '~/utils/shadcn-ui'; 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )); 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 28 | 41 | 42 | 43 | )); 44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 45 | 46 | export { ScrollArea, ScrollBar }; 47 | -------------------------------------------------------------------------------- /app/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority'; 2 | import * as React from 'react'; 3 | 4 | import { cn } from '~/utils/shadcn-ui'; 5 | 6 | const alertVariants = cva( 7 | 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:text-foreground [&>svg~*]:pl-7', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-background text-foreground', 12 | destructive: 13 | 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive bg-destructive/10', 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: 'default', 18 | }, 19 | }, 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |

32 | )); 33 | Alert.displayName = 'Alert'; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )); 45 | AlertTitle.displayName = 'AlertTitle'; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )); 57 | AlertDescription.displayName = 'AlertDescription'; 58 | 59 | export { Alert, AlertDescription, AlertTitle }; 60 | -------------------------------------------------------------------------------- /app/test/mocks/handlers/clerk.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw'; 2 | 3 | export const clerkHandlers = [ 4 | http.get( 5 | /https:\/\/([\dA-Za-z]+(-[\dA-Za-z]+)+)\.clerk\.accounts\.dev\/v1\/client\?_clerk_js_version=(\d+(\.\d+)+)&__clerk_db_jwt=([\dA-Za-z]+(_[\dA-Za-z]+)+)/, 6 | () => HttpResponse.json({ message: 'success' }, { status: 200 }), 7 | ), 8 | http.post( 9 | /https:\/\/([\dA-Za-z]+(-[\dA-Za-z]+)+)\.clerk\.accounts\.dev\/v1\/environment\?_clerk_js_version=(\d+(\.\d+)+)&_method=PATCH&__clerk_db_jwt=([\dA-Za-z]+(_[\dA-Za-z]+)+)/, 10 | () => HttpResponse.json({ message: 'success' }, { status: 200 }), 11 | ), 12 | http.get( 13 | /https:\/\/([\dA-Za-z]+(-[\dA-Za-z]+)+)\.clerk\.accounts\.dev\/npm\/@clerk\/clerk-js@5\/dist\/clerk\.browser\.js/, 14 | () => HttpResponse.json({ message: 'success' }, { status: 200 }), 15 | ), 16 | 17 | http.get( 18 | /https:\/\/([\dA-Za-z]+(-[\dA-Za-z]+)+)\.clerk\.accounts\.dev\/npm\/@clerk\/clerk-js@5\.24\.0\/dist\/clerk\.browser\.js/, 19 | () => HttpResponse.json({ message: 'success' }, { status: 200 }), 20 | ), 21 | 22 | http.get( 23 | /https:\/\/([\dA-Za-z]+(-[\dA-Za-z]+)+)\.clerk\.accounts\.dev\/npm\/@clerk\/clerk-js@5\.24\.0\/dist\/ui-common_476f85_5\.24\.0\.js/, 24 | () => HttpResponse.json({ message: 'success' }, { status: 200 }), 25 | ), 26 | 27 | http.get( 28 | /https:\/\/([\dA-Za-z]+(-[\dA-Za-z]+)+)\.clerk\.accounts\.dev\/npm\/@clerk\/clerk-js@5\.24\.0\/dist\/vendors_476f85_5\.24\.0\.js/, 29 | () => HttpResponse.json({ message: 'success' }, { status: 200 }), 30 | ), 31 | 32 | http.get( 33 | /https:\/\/([\dA-Za-z]+(-[\dA-Za-z]+)+)\.clerk\.accounts\.dev\/v1\/client\/handshake\?redirect_url=http%3A%2F%2Flocalhost%3A3000%2F&suffixed_cookies=false&__clerk_hs_reason=dev-browser-missing/, 34 | () => HttpResponse.json({ message: 'success' }, { status: 200 }), 35 | ), 36 | ]; 37 | -------------------------------------------------------------------------------- /app/features/user-authentication/clerk-sdk.server.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@clerk/remix/api.server'; 2 | import { createClerkClient } from '@clerk/remix/api.server'; 3 | import { getAuth } from '@clerk/remix/ssr.server'; 4 | import { prop } from 'ramda'; 5 | 6 | import { asyncPipe } from '~/utils/async-pipe'; 7 | 8 | export const clerkSdkServer = createClerkClient({ 9 | secretKey: process.env.CLERK_SECRET_KEY, 10 | }); 11 | 12 | export const getUserFromClerkById = async (userId: string) => { 13 | return await clerkSdkServer.users.getUser(userId); 14 | }; 15 | 16 | export const getSessionFromClerk = async (sessionToken: string) => { 17 | return await clerkSdkServer.sessions.getSession(sessionToken); 18 | }; 19 | 20 | export const getUserAndSessionFromClerk = async (sessionToken: string) => { 21 | const session = await getSessionFromClerk(sessionToken); 22 | const user = await getUserFromClerkById(session.userId); 23 | return { user, session }; 24 | }; 25 | 26 | export const getUserFromClerkBySessionId = asyncPipe( 27 | getSessionFromClerk, 28 | prop('userId'), 29 | getUserFromClerkById, 30 | ); 31 | 32 | export const revokeClerkSessionFromRequest = async (request: Request) => { 33 | const auth = await getAuth( 34 | { request, params: {}, context: {} }, 35 | { secretKey: process.env.CLERK_SECRET_KEY }, 36 | ); 37 | if (auth.sessionId) { 38 | await clerkSdkServer.sessions.revokeSession(auth.sessionId); 39 | } 40 | }; 41 | 42 | export const findUserClerkAccountByEmailAddress = async ( 43 | email: string, 44 | ): Promise => { 45 | const users = await clerkSdkServer.users.getUserList({ 46 | emailAddress: [email], 47 | }); 48 | return users.data[0]; 49 | }; 50 | 51 | export const createClerkUserAccount = async (email: string) => { 52 | return await clerkSdkServer.users.createUser({ 53 | emailAddress: [email], 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /app/features/localization/get-page-title.server.test.ts: -------------------------------------------------------------------------------- 1 | import type { TFunction } from 'i18next'; 2 | import { describe, expect, test } from 'vitest'; 3 | 4 | import { getPageTitle } from './get-page-title.server'; 5 | 6 | const t = ((key: string, options?: { name: string }) => 7 | ({ 8 | 'app-name': 'AppName', 9 | dashboard: 'Dashboard', 10 | greeting: `Hello ${options?.name}`, 11 | })[key] ?? null) as TFunction; 12 | 13 | describe('getPageTitle()', () => { 14 | test('given no tKey and prefix: returns the app name', () => { 15 | const actual = getPageTitle(t); 16 | const expected = 'AppName'; 17 | 18 | expect(actual).toEqual(expected); 19 | }); 20 | 21 | test('given a tKey as a string and no prefix: returns the translated tKey concatenated with the app name', () => { 22 | const tKey = 'dashboard'; 23 | const expectedTranslation = 'Dashboard'; 24 | 25 | const actual = getPageTitle(t, tKey); 26 | const expected = `${expectedTranslation} | AppName`; 27 | 28 | expect(actual).toEqual(expected); 29 | }); 30 | 31 | test('given a tKey as an object and no prefix: returns the translated tKey (with options) concatenated with the app name', () => { 32 | const tKey = { tKey: 'greeting', options: { name: 'Bob' } }; 33 | const expectedTranslation = 'Hello Bob'; 34 | 35 | const actual = getPageTitle(t, tKey); 36 | const expected = `${expectedTranslation} | AppName`; 37 | 38 | expect(actual).toEqual(expected); 39 | }); 40 | 41 | test('given a tKey and a prefix: returns the prefix, translated tKey, and app name', () => { 42 | const tKey = 'dashboard'; 43 | const prefix = 'Welcome to'; 44 | const expectedTranslation = 'Dashboard'; 45 | 46 | const actual = getPageTitle(t, tKey, prefix); 47 | const expected = `${prefix} ${expectedTranslation} | AppName`; 48 | 49 | expect(actual).toEqual(expected); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /public/locales/en/register.json: -------------------------------------------------------------------------------- 1 | { 2 | "already-a-member": "Already a member?", 3 | "create-your-account": "Create your account", 4 | "clerk-id-malformed-error": "A DID token must be string.", 5 | "clerk-id-missing": "Registration failed. No DID token.", 6 | "email-address": "Email address", 7 | "email-description": "Your account's email address.", 8 | "email-invalid": "A valid email consists of characters, '@' and '.'.", 9 | "email-must-be-string": "A valid email address must be string.", 10 | "email-placeholder": "Your email address ...", 11 | "email-required": "Please enter a valid email (required).", 12 | "failed-to-load-magic": "Failed to load authentication provider https://magic.link. Please reload the page to try again.", 13 | "log-in-to-your-account": "Log in to your account.", 14 | "missing-email-metadata": "Missing email from Magic metadata.", 15 | "missing-issuer-metadata": "Missing issuer from Magic metadata.", 16 | "register": "Register", 17 | "registering": "Registering ...", 18 | "registration-failed": "Registration failed. Please try again.", 19 | "terms-description": "By signing up, you are creating an account and you agree to our <1>Terms of Use and <2>Privacy Policy.", 20 | "terms-label": "I accept the terms and conditions.", 21 | "terms-must-be-accepted": "You must accept the terms and conditions.", 22 | "user-already-exists": "User with given email already exists. Did you mean to log in instead?", 23 | "verify-email": "Verify email", 24 | "verify-email-description": "A verification email has been sent to <1>{{email}}. Please click the link in the email to verify your account to complete the registration.", 25 | "back-to-register": "Go back to create account", 26 | "email-verified-description": "Email verification successful. Close this tab and return to the sign up page to continue.", 27 | "email-verification-failed-description": "Email verification failed. Token is invalid or expired. Please try again." 28 | } 29 | -------------------------------------------------------------------------------- /app/https.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { enforceHttps } from './https'; 4 | 5 | describe('enforceHttps()', () => { 6 | test('given a request with a HTTP url: should redirect to HTTPS', () => { 7 | expect.assertions(2); 8 | 9 | const request = new Request('http://example.com', { 10 | headers: new Headers({ 11 | 'X-Forwarded-Proto': 'http', 12 | }), 13 | }); 14 | 15 | try { 16 | enforceHttps(request); 17 | } catch (error) { 18 | if (error instanceof Response) { 19 | expect(error.status).toEqual(302); 20 | expect(error.headers.get('Location')).toEqual('https://example.com/'); 21 | } 22 | } 23 | }); 24 | 25 | test('given a request with a HTTP url and query params: should redirect to HTTPS and keep params', () => { 26 | expect.assertions(2); 27 | 28 | const request = new Request('http://example.com?foo=bar', { 29 | headers: new Headers({ 30 | 'X-Forwarded-Proto': 'http', 31 | }), 32 | }); 33 | 34 | try { 35 | enforceHttps(request); 36 | } catch (error) { 37 | if (error instanceof Response) { 38 | expect(error.status).toEqual(302); 39 | expect(error.headers.get('Location')).toEqual( 40 | 'https://example.com/?foo=bar', 41 | ); 42 | } 43 | } 44 | }); 45 | 46 | test('should not redirect if the request is already using HTTPS', () => { 47 | const request = new Request('https://example.com', { 48 | headers: new Headers({ 49 | 'X-Forwarded-Proto': 'https', 50 | }), 51 | }); 52 | 53 | expect(() => enforceHttps(request)).not.toThrow(); 54 | }); 55 | 56 | test('should not redirect if the request is using localhost', () => { 57 | const request = new Request('http://localhost', { 58 | headers: new Headers({ 59 | 'X-Forwarded-Proto': 'http', 60 | }), 61 | }); 62 | 63 | expect(() => enforceHttps(request)).not.toThrow(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /app/features/onboarding/onboarding-actions.server.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { createId } from '@paralleldrive/cuid2'; 3 | import type { ActionFunctionArgs } from '@remix-run/node'; 4 | import { redirect } from '@remix-run/node'; 5 | 6 | import { parseFormData } from '~/utils/parse-form-data.server'; 7 | 8 | import { ORGANIZATION_MEMBERSHIP_ROLES } from '../organizations/organizations-constants'; 9 | import { 10 | addMembersToOrganizationInDatabaseById, 11 | saveOrganizationToDatabase, 12 | } from '../organizations/organizations-model.server'; 13 | import { updateUserProfileInDatabaseById } from '../user-profile/user-profile-model.server'; 14 | import { 15 | onboardingOrganizationSchema, 16 | onboardingUserProfileSchema, 17 | } from './onboarding-client-schemas'; 18 | import { requireUserNeedsOnboarding } from './onboarding-helpers.server'; 19 | 20 | export const onboardingUserProfileAction = async ({ 21 | request, 22 | }: Pick) => { 23 | const user = await requireUserNeedsOnboarding(request); 24 | const data = await parseFormData(onboardingUserProfileSchema, request); 25 | 26 | await updateUserProfileInDatabaseById({ 27 | id: user.id, 28 | userProfile: { name: data.name }, 29 | }); 30 | return redirect('/onboarding/organization'); 31 | }; 32 | 33 | export const onboardingOrganizationAction = async ({ 34 | request, 35 | }: Pick) => { 36 | const user = await requireUserNeedsOnboarding(request); 37 | const data = await parseFormData(onboardingOrganizationSchema, request); 38 | 39 | const organization = await saveOrganizationToDatabase({ 40 | id: createId(), 41 | name: data.name, 42 | slug: faker.helpers.slugify(data.name).toLowerCase(), 43 | }); 44 | await addMembersToOrganizationInDatabaseById({ 45 | id: organization.id, 46 | members: [user.id], 47 | role: ORGANIZATION_MEMBERSHIP_ROLES.OWNER, 48 | }); 49 | return redirect(`/organizations/${organization.slug}`); 50 | }; 51 | -------------------------------------------------------------------------------- /app/features/settings/settings-loaders.server.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from '@remix-run/node'; 2 | import { json } from '@remix-run/node'; 3 | import { promiseHash } from 'remix-utils/promise'; 4 | 5 | import { getPageTitle } from '../localization/get-page-title.server'; 6 | import { i18next } from '../localization/i18next.server'; 7 | import { requireOnboardedUserProfileExists } from '../onboarding/onboarding-helpers.server'; 8 | import { 9 | getUsersOwnedOrganizations, 10 | mapUserDataToSettingsProps, 11 | } from './settings-helpers.server'; 12 | 13 | const retrieveOnboardedUserAndLocalization = 14 | (tKey: string) => 15 | async ({ request }: Pick) => { 16 | const user = await requireOnboardedUserProfileExists(request); 17 | 18 | const { t, locale } = await promiseHash({ 19 | t: i18next.getFixedT(request), 20 | locale: i18next.getLocale(request), 21 | }); 22 | 23 | return { 24 | t, 25 | locale, 26 | user, 27 | pageTitle: getPageTitle(t, tKey, ''), 28 | }; 29 | }; 30 | 31 | export const settingsUserProfileLoader = retrieveOnboardedUserAndLocalization( 32 | 'settings-user-profile:title', 33 | ); 34 | 35 | export const settingsLoader = async ({ 36 | request, 37 | params, 38 | }: Pick) => { 39 | const { user, ...rest } = await retrieveOnboardedUserAndLocalization( 40 | 'settings:settings', 41 | )({ request, params }); 42 | 43 | return json({ 44 | ...rest, 45 | ...mapUserDataToSettingsProps({ user }), 46 | }); 47 | }; 48 | 49 | export const settingsAccountLoader = async ({ 50 | request, 51 | params, 52 | }: Pick) => { 53 | const { user, ...rest } = await retrieveOnboardedUserAndLocalization( 54 | 'settings-account:title', 55 | )({ request, params }); 56 | 57 | return json({ 58 | ...rest, 59 | user, 60 | usersOwnedOrganizations: getUsersOwnedOrganizations(user), 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /app/utils/get-error-message.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { describe, expect, test } from 'vitest'; 3 | 4 | import { getErrorMessage } from './get-error-message'; 5 | 6 | describe('getErrorMessage()', () => { 7 | test("given an error: returns the error's message", () => { 8 | const message = faker.word.words(); 9 | 10 | expect(getErrorMessage(new Error(message))).toEqual(message); 11 | }); 12 | 13 | test('given a string is thrown: returns the string', () => { 14 | expect.assertions(1); 15 | 16 | const someString = faker.lorem.words(); 17 | 18 | try { 19 | throw someString; 20 | } catch (error) { 21 | expect(getErrorMessage(error)).toEqual(someString); 22 | } 23 | }); 24 | 25 | test('given a number is thrown: returns the number', () => { 26 | expect.assertions(1); 27 | 28 | const someNumber = 1; 29 | 30 | try { 31 | throw someNumber; 32 | } catch (error) { 33 | expect(getErrorMessage(error)).toEqual(JSON.stringify(someNumber)); 34 | } 35 | }); 36 | 37 | test("given an error that extended the custom error class: returns the error's message", () => { 38 | class CustomError extends Error { 39 | // eslint-disable-next-line no-useless-constructor 40 | public constructor(message: string) { 41 | super(message); 42 | } 43 | } 44 | 45 | const message = faker.word.words(); 46 | 47 | expect(getErrorMessage(new CustomError(message))).toEqual(message); 48 | }); 49 | 50 | test("given a custom error object with a message property: returns the object's message property", () => { 51 | const message = faker.word.words(); 52 | 53 | expect(getErrorMessage({ message })).toEqual(message); 54 | }); 55 | 56 | test('given circular references: handles them', () => { 57 | expect.assertions(1); 58 | 59 | const object = { circular: this }; 60 | 61 | try { 62 | throw object; 63 | } catch (error) { 64 | expect(getErrorMessage(error)).toEqual('[object Object]'); 65 | } 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /playwright/e2e/not-found/not-found.spec.ts: -------------------------------------------------------------------------------- 1 | import AxeBuilder from '@axe-core/playwright'; 2 | import { expect, test } from '@playwright/test'; 3 | 4 | import { deleteUserProfileFromDatabaseById } from '~/features/user-profile/user-profile-model.server'; 5 | 6 | import { getPath, loginAndSaveUserProfileToDatabase } from '../../utils'; 7 | 8 | test.describe('not found page', () => { 9 | test('given a logged out user: the page renders the correct title and a useful error message and a link that redirects the user to the landing page', async ({ 10 | page, 11 | }) => { 12 | await page.goto('/some-non-existing-url'); 13 | 14 | // It has the correct title and header. 15 | expect(await page.title()).toEqual('404 Not Found | French House Stack'); 16 | await expect( 17 | page.getByRole('heading', { name: /page not found/i, level: 1 }), 18 | ).toBeVisible(); 19 | 20 | // It renders a link to navigate to the landing page. 21 | await page.getByRole('link', { name: /home/i }).click(); 22 | expect(getPath(page)).toEqual('/'); 23 | }); 24 | 25 | test('given a logged in user that is NOT onboarded: the page renders a link that redirects the user to the onboarding page', async ({ 26 | page, 27 | }) => { 28 | const { id } = await loginAndSaveUserProfileToDatabase({ name: '', page }); 29 | await page.goto('/some-non-existing-url'); 30 | 31 | // Clicking the home button navigates the user to the onboarding page. 32 | await page.getByRole('link', { name: /home/i }).click(); 33 | expect(getPath(page)).toEqual(`/onboarding/user-profile`); 34 | 35 | await page.close(); 36 | await deleteUserProfileFromDatabaseById(id); 37 | }); 38 | 39 | test('page should lack any automatically detectable accessibility issues', async ({ 40 | page, 41 | }) => { 42 | await page.goto('/some-non-existing-url'); 43 | 44 | const accessibilityScanResults = await new AxeBuilder({ page }) 45 | .disableRules('color-contrast') 46 | .analyze(); 47 | 48 | expect(accessibilityScanResults.violations).toEqual([]); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import * as React from 'react'; 4 | 5 | import { cn } from '~/utils/shadcn-ui'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 14 | destructive: 15 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 16 | outline: 17 | 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 18 | secondary: 19 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 20 | ghost: 'hover:bg-accent hover:text-accent-foreground', 21 | link: 'text-primary underline-offset-4 hover:underline', 22 | }, 23 | size: { 24 | default: 'h-9 px-4 py-2', 25 | sm: 'h-8 rounded-md px-3 text-xs', 26 | lg: 'h-10 rounded-md px-8', 27 | icon: 'h-9 w-9', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : 'button'; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = 'Button'; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@types/eslint').Linter.BaseConfig} 3 | */ 4 | module.exports = { 5 | extends: [ 6 | '@remix-run/eslint-config', 7 | '@remix-run/eslint-config/node', 8 | 'plugin:unicorn/recommended', 9 | 'plugin:import/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | plugins: ['simple-import-sort'], 13 | rules: { 14 | 'simple-import-sort/imports': 'error', 15 | 'simple-import-sort/exports': 'error', 16 | 'unicorn/no-array-callback-reference': 'off', 17 | 'unicorn/no-array-for-each': 'off', 18 | 'unicorn/no-array-reduce': 'off', 19 | 'unicorn/prevent-abbreviations': [ 20 | 'error', 21 | { 22 | allowList: { 23 | e2e: true, 24 | 'remix.env.d': true, 25 | }, 26 | replacements: { 27 | env: false, 28 | props: false, 29 | ref: false, 30 | params: false, 31 | }, 32 | }, 33 | ], 34 | 'unicorn/import-style': 'off', 35 | 'unicorn/filename-case': [ 36 | 'error', 37 | { 38 | case: 'kebabCase', 39 | ignore: [ 40 | /.*\._index\.tsx$/, 41 | /.*\$[A-Za-z]+Slug(\.[A-Za-z]+)*\.tsx$/, 42 | /.*organizations_.*\..+$/, 43 | ], 44 | }, 45 | ], 46 | }, 47 | overrides: [ 48 | { 49 | files: ['*.js'], 50 | rules: { 51 | 'unicorn/prefer-module': 'off', 52 | }, 53 | }, 54 | { 55 | files: ['*.spec.ts'], 56 | extends: ['plugin:playwright/recommended'], 57 | rules: { 58 | 'playwright/require-top-level-describe': 'error', 59 | 'unicorn/no-null': 'off', 60 | 'playwright/no-conditional-expect': 'off', 61 | }, 62 | }, 63 | { 64 | files: ['*.test.ts', '*.test.tsx'], 65 | extends: ['@remix-run/eslint-config/jest-testing-library'], 66 | settings: { 67 | jest: { 68 | version: 27, 69 | }, 70 | }, 71 | rules: { 72 | 'jest/no-conditional-expect': 'off', 73 | 'unicorn/no-null': 'off', 74 | }, 75 | }, 76 | ], 77 | }; 78 | -------------------------------------------------------------------------------- /app/test/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { type RequestHandler } from 'msw'; 2 | import { type SetupServer, setupServer } from 'msw/node'; 3 | 4 | import { onUnhandledRequest } from './msw-utils'; 5 | import { remixPingHandler } from './msw-utils.server'; 6 | 7 | /** 8 | * During development, we need to save the instance of our MSW server in a 9 | * global variable. 10 | * Remix purges the 'require' cache on every request in development to support 11 | * functionality from the server to the browser. 12 | * To make sure our cache survives these purges during development, we need to 13 | * assign it to the `global` object 14 | * 15 | * Details: https://stackoverflow.com/questions/72661999/how-do-i-use-in-memory-cache-in-remix-run-dev-mode 16 | * Inspired by: https://github.com/kettanaito/msw-with-remix/blob/main/app/mocks/server.ts 17 | * And: https://github.com/cliffordfajardo/remix-msw-node-with-playwright/blob/main/app/msw-server.ts 18 | */ 19 | declare global { 20 | var __MSW_SERVER: SetupServer | undefined; 21 | } 22 | 23 | function setup(handlers: RequestHandler[]) { 24 | const server = setupServer(remixPingHandler, ...handlers); 25 | globalThis.__MSW_SERVER = server; 26 | return server; 27 | } 28 | 29 | function start(server: SetupServer) { 30 | server.listen({ onUnhandledRequest }); 31 | console.info('🔶 MSW mock server running ...'); 32 | 33 | process.once('SIGINT', () => { 34 | globalThis.__MSW_SERVER = undefined; 35 | server.close(); 36 | }); 37 | 38 | process.once('SIGTERM', () => { 39 | globalThis.__MSW_SERVER = undefined; 40 | server.close(); 41 | }); 42 | } 43 | 44 | function restart(server: SetupServer, handlers: RequestHandler[]) { 45 | console.info('🔶 Shutting down MSW Mock Server ...'); 46 | server.close(); 47 | 48 | console.info('🔶 Attempting to restart MSW Mock Server ...'); 49 | start(setup(handlers)); 50 | } 51 | 52 | export function startMockServer(handlers: RequestHandler[]) { 53 | const persistedServer = globalThis.__MSW_SERVER; 54 | 55 | if (persistedServer === undefined) { 56 | start(setup(handlers)); 57 | } else { 58 | restart(persistedServer, handlers); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/features/organizations/accept-membership-invite-page-component.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { createRemixStub, render, screen } from '~/test/react-test-utils'; 4 | import type { Factory } from '~/utils/types'; 5 | 6 | import { createPopulatedUserProfile } from '../user-profile/user-profile-factories.server'; 7 | import type { AcceptMembershipInvitePageComponentProps } from './accept-membership-invite-page-component'; 8 | import { AcceptMembershipInvitePageComponent } from './accept-membership-invite-page-component'; 9 | import { createPopulatedOrganization } from './organizations-factories.server'; 10 | 11 | const createProps: Factory = ({ 12 | inviterName = createPopulatedUserProfile().name, 13 | organizationName = createPopulatedOrganization().name, 14 | ...props 15 | } = {}) => ({ inviterName, organizationName, ...props }); 16 | 17 | describe('AcceptMembershipInvitePage component', () => { 18 | test('given an organization name and an inviter name: renders a greeting and a button to accept the invite', () => { 19 | const props = createProps(); 20 | const path = `/organizations/invite`; 21 | const RemixStub = createRemixStub([ 22 | { 23 | path, 24 | Component: () => , 25 | }, 26 | ]); 27 | 28 | render(); 29 | 30 | // It renders a greeting. 31 | expect( 32 | screen.getByText(/welcome to french house stack/i), 33 | ).toBeInTheDocument(); 34 | expect( 35 | screen.getByText( 36 | new RegExp( 37 | `${props.inviterName} invites you to join ${props.organizationName}`, 38 | 'i', 39 | ), 40 | ), 41 | ).toBeInTheDocument(); 42 | expect( 43 | screen.getByText( 44 | /click the button below to sign up. by using this link you will automatically join the correct organization./i, 45 | ), 46 | ).toBeInTheDocument(); 47 | 48 | // It renders a button to accept the invite. 49 | expect( 50 | screen.getByRole('button', { name: /accept invite/i }), 51 | ).toHaveAttribute('type', 'submit'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /app/components/general-error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import type { ErrorResponse } from '@remix-run/react'; 2 | import { 3 | isRouteErrorResponse, 4 | useParams, 5 | useRouteError, 6 | } from '@remix-run/react'; 7 | import { captureRemixErrorBoundaryError } from '@sentry/remix'; 8 | 9 | import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'; 10 | import { getErrorMessage } from '~/utils/get-error-message'; 11 | 12 | type StatusHandler = (info: { 13 | error: ErrorResponse; 14 | params: Record; 15 | }) => JSX.Element | null; 16 | 17 | const ErrorMessage = ({ 18 | title, 19 | description, 20 | }: { 21 | title: string; 22 | description: string; 23 | }) => ( 24 |
25 | 29 | {title} 30 | {description} 31 | 32 |
33 | ); 34 | 35 | /** 36 | * @see https://github.com/epicweb-dev/epic-stack/blob/main/app/components/error-boundary.tsx 37 | */ 38 | export function GeneralErrorBoundary({ 39 | defaultStatusHandler = ({ error }) => ( 40 | 44 | ), 45 | statusHandlers, 46 | unexpectedErrorHandler = error => ( 47 | 48 | ), 49 | }: { 50 | defaultStatusHandler?: StatusHandler; 51 | statusHandlers?: Record; 52 | unexpectedErrorHandler?: (error: unknown) => JSX.Element | null; 53 | }) { 54 | const error = useRouteError(); 55 | captureRemixErrorBoundaryError(error); 56 | const params = useParams(); 57 | 58 | if (typeof document !== 'undefined') { 59 | console.error(error); 60 | } 61 | 62 | return ( 63 |
64 | {isRouteErrorResponse(error) 65 | ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ 66 | error, 67 | params, 68 | }) 69 | : unexpectedErrorHandler(error)} 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /app/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as AccordionPrimitive from '@radix-ui/react-accordion'; 2 | import { ChevronDownIcon } from 'lucide-react'; 3 | import * as React from 'react'; 4 | 5 | import { cn } from '~/utils/shadcn-ui'; 6 | 7 | const Accordion = AccordionPrimitive.Root; 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 18 | )); 19 | AccordionItem.displayName = 'AccordionItem'; 20 | 21 | const AccordionTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, children, ...props }, ref) => ( 25 | 26 | svg]:rotate-180', 30 | className, 31 | )} 32 | {...props} 33 | > 34 | {children} 35 | 36 | 37 | 38 | )); 39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 40 | 41 | const AccordionContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 50 |
{children}
51 |
52 | )); 53 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 54 | 55 | export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; 56 | -------------------------------------------------------------------------------- /app/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '~/utils/shadcn-ui'; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = 'Card'; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = 'CardHeader'; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )); 42 | CardTitle.displayName = 'CardTitle'; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )); 54 | CardDescription.displayName = 'CardDescription'; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )); 62 | CardContent.displayName = 'CardContent'; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = 'CardFooter'; 75 | 76 | export { 77 | Card, 78 | CardContent, 79 | CardDescription, 80 | CardFooter, 81 | CardHeader, 82 | CardTitle, 83 | }; 84 | -------------------------------------------------------------------------------- /templates/app/features/feature/feature-model.server.hbs: -------------------------------------------------------------------------------- 1 | import type { {{pascalCase name}} } from '@prisma/client'; 2 | 3 | import { prisma } from '~/database.server'; 4 | 5 | export type Partial{{pascalCase name}}Parameters = Pick< 6 | Parameters[0]['data'], 7 | 'id' 8 | >; 9 | 10 | // CREATE 11 | 12 | /** 13 | * Saves a new {{name}} to the database. 14 | * 15 | * @param {{name}} - Parameters of the {{name}} that should be created. 16 | * @returns The newly created {{name}}. 17 | */ 18 | export async function save{{pascalCase name}}ToDatabase({{camelCase name}}: Partial{{pascalCase name}}Parameters) { 19 | return await prisma.{{camelCase name}}.create({ data: {{camelCase name}} }); 20 | } 21 | 22 | // READ 23 | 24 | /** 25 | * Retrieves a {{name}} record from the database based on its id. 26 | * 27 | * @param id - The id of the {{name}} to get. 28 | * @returns The {{name}} with a given id or null if it wasn't found. 29 | */ 30 | export async function retrieve{{pascalCase name}}FromDatabaseById(id: {{pascalCase name}}['id']) { 31 | return await prisma.{{camelCase name}}.findUnique({ where: { id } }); 32 | } 33 | 34 | // UPDATE 35 | 36 | /** 37 | * Updates a {{name}} in the database. 38 | * 39 | * @param options - A an object with the {{name}}'s id and the new values. 40 | * @returns The updated {{name}}. 41 | */ 42 | export async function update{{pascalCase name}}InDatabaseById({ 43 | id, 44 | {{camelCase name}}, 45 | }: { 46 | /** 47 | * The id of the {{name}} you want to update. 48 | */ 49 | id: {{pascalCase name}}['id']; 50 | /** 51 | * The values of the {{name}} you want to change. 52 | */ 53 | {{camelCase name}}: Partial[0]['data'], 'id'>>; 54 | }) { 55 | return await prisma.{{camelCase name}}.update({ where: { id }, data: {{camelCase name}} }); 56 | } 57 | 58 | // DELETE 59 | 60 | /** 61 | * Removes a {{name}} from the database. 62 | * 63 | * @param id - The id of the {{name}} you want to delete. 64 | * @returns The {{name}} that was deleted. 65 | */ 66 | export async function delete{{pascalCase name}}FromDatabaseById(id: {{pascalCase name}}['id']) { 67 | return await prisma.{{camelCase name}}.delete({ where: { id } }); 68 | } 69 | -------------------------------------------------------------------------------- /app/features/settings/settings-actions.server.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunctionArgs } from '@remix-run/node'; 2 | import { json } from '@remix-run/node'; 3 | import { promiseHash } from 'remix-utils/promise'; 4 | 5 | import { badRequest } from '~/utils/http-responses.server'; 6 | import { parseFormData } from '~/utils/parse-form-data.server'; 7 | import { createToastHeaders } from '~/utils/toast.server'; 8 | 9 | import { i18next } from '../localization/i18next.server'; 10 | import { requireOnboardedUserProfileExists } from '../onboarding/onboarding-helpers.server'; 11 | import { logout } from '../user-authentication/user-authentication-helpers.server'; 12 | import { 13 | deleteUserProfileFromDatabaseById, 14 | updateUserProfileInDatabaseById, 15 | } from '../user-profile/user-profile-model.server'; 16 | import { 17 | settingsAccountSchema, 18 | settingsUserProfileSchema, 19 | } from './settings-client-schemas'; 20 | import { getUsersOwnedOrganizations } from './settings-helpers.server'; 21 | 22 | export const settingsUserProfileAction = async ({ 23 | request, 24 | }: Pick) => { 25 | const user = await requireOnboardedUserProfileExists(request); 26 | const { data, t } = await promiseHash({ 27 | data: parseFormData(settingsUserProfileSchema, request), 28 | t: i18next.getFixedT(request), 29 | }); 30 | 31 | if (user.name !== data.name) { 32 | await updateUserProfileInDatabaseById({ 33 | id: user.id, 34 | userProfile: { name: data.name }, 35 | }); 36 | } 37 | 38 | const headers = await createToastHeaders({ 39 | title: t('settings-user-profile:user-profile-updated'), 40 | }); 41 | 42 | return json({ success: true }, { headers }); 43 | }; 44 | 45 | export const settingsAccountAction = async ({ 46 | request, 47 | }: Pick) => { 48 | const user = await requireOnboardedUserProfileExists(request); 49 | await parseFormData(settingsAccountSchema, request); 50 | 51 | const usersOwnedOrganizations = getUsersOwnedOrganizations(user); 52 | 53 | if (usersOwnedOrganizations.length > 0) { 54 | return badRequest({ error: 'settings-account:still-an-owner' }); 55 | } 56 | 57 | await deleteUserProfileFromDatabaseById(user.id); 58 | 59 | return await logout(request); 60 | }; 61 | -------------------------------------------------------------------------------- /app/components/card-pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Trans, useTranslation } from 'react-i18next'; 2 | 3 | import { cn } from '~/utils/shadcn-ui'; 4 | 5 | import { DisableableLink } from './disableable-link'; 6 | import { Text } from './text'; 7 | import { buttonVariants } from './ui/button'; 8 | 9 | export type CardPaginationProps = { 10 | className?: string; 11 | currentPage: number; 12 | perPage?: number; 13 | totalItemCount: number; 14 | }; 15 | 16 | export function CardPagination({ 17 | className, 18 | currentPage, 19 | perPage = 10, 20 | totalItemCount, 21 | }: CardPaginationProps) { 22 | const { t } = useTranslation('pagination'); 23 | const min = (currentPage - 1) * perPage + 1; 24 | const max = Math.min(currentPage * perPage, totalItemCount); 25 | const disablePrevious = currentPage === 1; 26 | const disableNext = currentPage * perPage >= totalItemCount; 27 | 28 | return ( 29 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /app/utils/combine-headers.server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { combineHeaders } from './combine-headers.server'; 4 | 5 | /** 6 | * Converts a headers object to a plain object while normalizing the headers to 7 | * lowercase. 8 | * 9 | * NOTE: This is a helper function for testing purposes only. The real headers 10 | * won't be normalized to lowercase. We need this function because Vitest can't 11 | * compare Headers objects. 12 | * 13 | * @param headers - A Headers object. 14 | * @returns A headers object with the keys normalized to lowercase. 15 | */ 16 | const headersToObject = (headers: Headers) => 17 | Object.fromEntries(headers.entries()); 18 | 19 | describe('combineHeaders()', () => { 20 | test('given multiple headers objects: returns a combined headers object', () => { 21 | const headers1 = new Headers({ 'Content-Type': 'application/json' }); 22 | const headers2 = new Headers({ Accept: 'application/xml' }); 23 | 24 | const actual = headersToObject(combineHeaders(headers1, headers2)); 25 | const expected = { 26 | 'content-type': 'application/json', 27 | accept: 'application/xml', 28 | }; 29 | 30 | expect(actual).toEqual(expected); 31 | }); 32 | 33 | test('given headers with overlapping keys: returns a combined headers object with appended values', () => { 34 | const headers1 = new Headers({ 'Cache-Control': 'no-cache' }); 35 | const headers2 = new Headers({ 'Cache-Control': 'no-store' }); 36 | 37 | const actual = headersToObject(combineHeaders(headers1, headers2)); 38 | const expected = { 'cache-control': 'no-cache, no-store' }; 39 | 40 | expect(actual).toEqual(expected); 41 | }); 42 | 43 | test('given null or undefined headers: returns a combined headers object excluding null or undefined headers', () => { 44 | const headers = new Headers({ 'X-Custom-Header': 'value1' }); 45 | 46 | const actual = headersToObject(combineHeaders(headers, undefined, null)); 47 | const expected = { 'x-custom-header': 'value1' }; 48 | 49 | expect(actual).toEqual(expected); 50 | }); 51 | 52 | test('given no headers: returns an empty headers object', () => { 53 | const actual = headersToObject(combineHeaders()); 54 | const expected = {}; 55 | 56 | expect(actual).toEqual(expected); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from '@remix-run/react'; 2 | import i18next from 'i18next'; 3 | import LanguageDetector from 'i18next-browser-languagedetector'; 4 | import Backend from 'i18next-http-backend'; 5 | import { startTransition, StrictMode } from 'react'; 6 | import { hydrateRoot } from 'react-dom/client'; 7 | import { I18nextProvider, initReactI18next } from 'react-i18next'; 8 | import { getInitialNamespaces } from 'remix-i18next/client'; 9 | 10 | import { i18n } from './features/localization/i18n'; 11 | import { onUnhandledRequest } from './test/mocks/msw-utils'; 12 | 13 | export type EnvironmentVariables = { 14 | CLIENT_MOCKS?: string; 15 | SENTRY_DSN?: string; 16 | ENVIRONMENT?: string; 17 | }; 18 | 19 | declare global { 20 | var ENV: EnvironmentVariables; 21 | 22 | interface Window { 23 | runMagicInTestMode?: boolean; 24 | } 25 | } 26 | 27 | if (ENV.ENVIRONMENT === 'production' && ENV.SENTRY_DSN) { 28 | // eslint-disable-next-line unicorn/prefer-top-level-await 29 | import('./features/monitoring/monitoring-helpers.client').then( 30 | ({ initializeClientMonitoring }) => initializeClientMonitoring(), 31 | ); 32 | } 33 | 34 | async function activateMsw() { 35 | if (ENV.CLIENT_MOCKS === 'true') { 36 | const { worker } = await import('./test/mocks/browser'); 37 | 38 | return worker.start({ onUnhandledRequest }); 39 | } 40 | 41 | return; 42 | } 43 | 44 | async function hydrate() { 45 | await activateMsw(); 46 | 47 | await i18next 48 | .use(initReactI18next) 49 | .use(LanguageDetector) 50 | .use(Backend) 51 | .init({ 52 | ...i18n, 53 | ns: getInitialNamespaces(), 54 | backend: { 55 | loadPath: '/locales/{{lng}}/{{ns}}.json', 56 | requestOptions: { cache: 'no-cache' }, 57 | }, 58 | detection: { order: ['htmlTag'], caches: [] }, 59 | }); 60 | 61 | startTransition(() => { 62 | hydrateRoot( 63 | document, 64 | 65 | 66 | 67 | 68 | , 69 | ); 70 | }); 71 | } 72 | 73 | if (window.requestIdleCallback) { 74 | window.requestIdleCallback(hydrate); 75 | } else { 76 | // Safari doesn't support requestIdleCallback 77 | // https://caniuse.com/requestidlecallback 78 | window.setTimeout(hydrate, 1); 79 | } 80 | -------------------------------------------------------------------------------- /app/features/organizations/organizations-switcher-component.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { 4 | createRemixStub, 5 | render, 6 | screen, 7 | userEvent, 8 | } from '~/test/react-test-utils'; 9 | import type { Factory } from '~/utils/types'; 10 | 11 | import { createPopulatedOrganization } from './organizations-factories.server'; 12 | import type { OrganizationsSwitcherComponentProps } from './organizations-switcher-component'; 13 | import { OrganizationsSwitcherComponent } from './organizations-switcher-component'; 14 | 15 | const createProps: Factory = ({ 16 | organizations = [ 17 | { 18 | isCurrent: false, 19 | name: createPopulatedOrganization().name, 20 | slug: createPopulatedOrganization().slug, 21 | }, 22 | { 23 | isCurrent: true, 24 | name: createPopulatedOrganization().name, 25 | slug: createPopulatedOrganization().slug, 26 | }, 27 | ], 28 | } = {}) => ({ organizations }); 29 | 30 | describe('OrganizationsSwitcher component', () => { 31 | test("given a list of the user's organizations: lets the user click on the current organization to expand the menu to show all and links to the page to create new organizations", async () => { 32 | const user = userEvent.setup(); 33 | const props = createProps(); 34 | const path = `/organizations/${props.organizations[1].slug}/home`; 35 | const RemixStub = createRemixStub([ 36 | { 37 | path, 38 | Component: () => , 39 | }, 40 | ]); 41 | 42 | render(); 43 | 44 | // Open the organization switcher menu. 45 | await user.click( 46 | screen.getByRole('combobox', { name: /select an organization/i }), 47 | ); 48 | 49 | // Shows the other organizations and a link to create a new organization. 50 | props.organizations.forEach(organization => { 51 | expect( 52 | screen.getByRole('option', { name: organization.name }), 53 | ).toBeInTheDocument(); 54 | expect( 55 | screen.getByRole('link', { name: organization.name }), 56 | ).toHaveAttribute('href', `/organizations/${organization.slug}/home`); 57 | }); 58 | expect( 59 | screen.getByRole('link', { name: /create organization/i }), 60 | ).toHaveAttribute('href', '/organizations/new'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /app/routes/organizations_.$organizationSlug.settings.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; 2 | import { NavLink, Outlet, useLoaderData } from '@remix-run/react'; 3 | 4 | import { GeneralErrorBoundary } from '~/components/general-error-boundary'; 5 | import { buttonVariants } from '~/components/ui/button'; 6 | import { useTranslation } from '~/features/localization/use-translation'; 7 | import { organizationSettingsLoader } from '~/features/organizations/organizations-loaders.server'; 8 | import { cn } from '~/utils/shadcn-ui'; 9 | 10 | export const handle = { i18n: ['organizations', 'organization-settings'] }; 11 | 12 | export async function loader({ request, params }: LoaderFunctionArgs) { 13 | return await organizationSettingsLoader({ request, params }); 14 | } 15 | 16 | export const meta: MetaFunction = ({ data }) => [ 17 | { title: data?.pageTitle || 'Settings' }, 18 | ]; 19 | 20 | export default function OrganizationsSettings() { 21 | const { t } = useTranslation('organization-settings'); 22 | const { organizationSlug } = useLoaderData(); 23 | 24 | const settingsNavItems = [ 25 | { 26 | name: t('general'), 27 | href: `/organizations/${organizationSlug}/settings/profile`, 28 | }, 29 | { 30 | name: t('team-members'), 31 | href: `/organizations/${organizationSlug}/settings/team-members`, 32 | }, 33 | ]; 34 | 35 | return ( 36 | <> 37 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | export function ErrorBoundary() { 68 | return ; 69 | } 70 | -------------------------------------------------------------------------------- /app/features/user-authentication/awaiting-email-confirmation.tsx: -------------------------------------------------------------------------------- 1 | import { Trans } from 'react-i18next'; 2 | 3 | import { Text, TextLink } from '~/components/text'; 4 | import { Button } from '~/components/ui/button'; 5 | import { TypographyH1 } from '~/components/ui/typography'; 6 | import { useTranslation } from '~/features/localization/use-translation'; 7 | 8 | export const RegisterAwaitingEmailVerification = ({ 9 | onCancel, 10 | email, 11 | token, 12 | }: { 13 | onCancel?: () => void; 14 | email: string; 15 | token?: string; 16 | }) => { 17 | const { t } = useTranslation('register'); 18 | 19 | return ( 20 |
21 | 22 | {t('verify-email')} 23 | 24 | 25 |
26 | 27 | }} 29 | i18nKey="register:verify-email-description" 30 | values={{ email }} 31 | /> 32 | 33 |
34 | 35 |
36 | 37 | 38 | {t('already-a-member')}{' '} 39 | 40 | {t('log-in-to-your-account')} 41 | 42 | 43 |
44 |
45 | ); 46 | }; 47 | export const AwaitingLoginEmailVerification = ({ 48 | email, 49 | onCancel, 50 | }: { 51 | onCancel?: () => void; 52 | email: string; 53 | }) => { 54 | const { t } = useTranslation('login'); 55 | 56 | return ( 57 |
58 | 59 | {t('verify-email')} 60 | 61 | 62 |
63 | 64 | }} 66 | i18nKey="login:verify-email-description" 67 | values={{ email }} 68 | /> 69 | 70 |
71 | 72 |
73 | 74 |
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /app/routes/settings.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; 2 | import { NavLink, Outlet, useLoaderData } from '@remix-run/react'; 3 | 4 | import { 5 | Header, 6 | HeaderBackButton, 7 | HeaderSeperator, 8 | HeaderTitle, 9 | HeaderUserProfileDropdown, 10 | } from '~/components/header'; 11 | import { buttonVariants } from '~/components/ui/button'; 12 | import { useTranslation } from '~/features/localization/use-translation'; 13 | import { settingsLoader } from '~/features/settings/settings-loaders.server'; 14 | import { cn } from '~/utils/shadcn-ui'; 15 | 16 | export const handle = { i18n: ['header', 'settings'] }; 17 | 18 | export async function loader({ request, params }: LoaderFunctionArgs) { 19 | return await settingsLoader({ request, params }); 20 | } 21 | 22 | export const meta: MetaFunction = ({ data }) => [ 23 | { title: data?.pageTitle || 'New Organization' }, 24 | ]; 25 | 26 | export default function Settings() { 27 | const { t } = useTranslation('settings'); 28 | const settingsNavItems = [ 29 | { name: t('profile'), href: '/settings/profile' }, 30 | { name: t('account'), href: '/settings/account' }, 31 | ]; 32 | const { userNavigation } = useLoaderData(); 33 | 34 | return ( 35 |
36 |
37 | 38 | 39 | 40 | 41 | {t('settings')} 42 | 43 | 44 | 45 | 46 |
47 | 48 | 72 | 73 | 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/utils/to-form-data.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { toFormData } from './to-form-data'; 4 | 5 | describe('toFormData()', () => { 6 | test('given an object: returns the valid form data', () => { 7 | const payload = { 8 | text: 'Hello', 9 | file: new Blob(['content'], { type: 'text/plain' }), 10 | questions: ['What is up?', 'Can you tell me?'], 11 | }; 12 | 13 | const actual = toFormData(payload); 14 | const expected = new FormData(); 15 | expected.append('text', 'Hello'); 16 | expected.append('file', new Blob(['content'], { type: 'text/plain' })); 17 | expected.append('questions', 'What is up?'); 18 | expected.append('questions', 'Can you tell me?'); 19 | 20 | expect(actual).toEqual(expected); 21 | }); 22 | 23 | test('given an empty object: returns an empty form data', () => { 24 | const payload = {}; 25 | 26 | const actual = toFormData(payload); 27 | const expected = new FormData(); 28 | 29 | expect(actual).toEqual(expected); 30 | }); 31 | 32 | test('given an object with only string values: returns the valid form data', () => { 33 | const payload = { 34 | name: 'John Doe', 35 | age: '30', 36 | }; 37 | 38 | const actual = toFormData(payload); 39 | const expected = new FormData(); 40 | expected.append('name', 'John Doe'); 41 | expected.append('age', '30'); 42 | 43 | expect(actual).toEqual(expected); 44 | }); 45 | 46 | test('given an object with only Blob values: returns the valid form data', () => { 47 | const payload = { 48 | file1: new Blob(['content1'], { type: 'text/plain' }), 49 | file2: new Blob(['content2'], { type: 'text/plain' }), 50 | }; 51 | 52 | const actual = toFormData(payload); 53 | const expected = new FormData(); 54 | expected.append('file1', new Blob(['content1'], { type: 'text/plain' })); 55 | expected.append('file2', new Blob(['content2'], { type: 'text/plain' })); 56 | 57 | expect(actual).toEqual(expected); 58 | }); 59 | 60 | test('given an object with only array of string values: returns the valid form data', () => { 61 | const payload = { 62 | colors: ['red', 'blue', 'green'], 63 | sizes: ['S', 'M', 'L'], 64 | }; 65 | 66 | const actual = toFormData(payload); 67 | const expected = new FormData(); 68 | expected.append('colors', 'red'); 69 | expected.append('colors', 'blue'); 70 | expected.append('colors', 'green'); 71 | expected.append('sizes', 'S'); 72 | expected.append('sizes', 'M'); 73 | expected.append('sizes', 'L'); 74 | 75 | expect(actual).toEqual(expected); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config = { 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | prefix: '', 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: '2rem', 15 | screens: { 16 | '2xl': '1400px', 17 | }, 18 | }, 19 | extend: { 20 | colors: { 21 | border: 'hsl(var(--border))', 22 | input: 'hsl(var(--input))', 23 | ring: 'hsl(var(--ring))', 24 | background: 'hsl(var(--background))', 25 | foreground: 'hsl(var(--foreground))', 26 | primary: { 27 | DEFAULT: 'hsl(var(--primary))', 28 | foreground: 'hsl(var(--primary-foreground))', 29 | }, 30 | secondary: { 31 | DEFAULT: 'hsl(var(--secondary))', 32 | foreground: 'hsl(var(--secondary-foreground))', 33 | }, 34 | destructive: { 35 | DEFAULT: 'hsl(var(--destructive))', 36 | foreground: 'hsl(var(--destructive-foreground))', 37 | }, 38 | muted: { 39 | DEFAULT: 'hsl(var(--muted))', 40 | foreground: 'hsl(var(--muted-foreground))', 41 | }, 42 | accent: { 43 | DEFAULT: 'hsl(var(--accent))', 44 | foreground: 'hsl(var(--accent-foreground))', 45 | }, 46 | popover: { 47 | DEFAULT: 'hsl(var(--popover))', 48 | foreground: 'hsl(var(--popover-foreground))', 49 | }, 50 | card: { 51 | DEFAULT: 'hsl(var(--card))', 52 | foreground: 'hsl(var(--card-foreground))', 53 | }, 54 | }, 55 | borderRadius: { 56 | lg: 'var(--radius)', 57 | md: 'calc(var(--radius) - 2px)', 58 | sm: 'calc(var(--radius) - 4px)', 59 | }, 60 | keyframes: { 61 | 'accordion-down': { 62 | from: { height: '0' }, 63 | to: { height: 'var(--radix-accordion-content-height)' }, 64 | }, 65 | 'accordion-up': { 66 | from: { height: 'var(--radix-accordion-content-height)' }, 67 | to: { height: '0' }, 68 | }, 69 | }, 70 | screens: { 71 | xs: '400px', 72 | }, 73 | width: { 74 | '66': '16.5rem', 75 | '68': '17rem', 76 | '82': '20.5rem', 77 | }, 78 | animation: { 79 | 'accordion-down': 'accordion-down 0.2s ease-out', 80 | 'accordion-up': 'accordion-up 0.2s ease-out', 81 | }, 82 | spacing: { 83 | '13': '3.25rem', 84 | }, 85 | }, 86 | }, 87 | // eslint-disable-next-line unicorn/prefer-module 88 | plugins: [require('tailwindcss-animate')], 89 | } satisfies Config; 90 | 91 | export default config; 92 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { exit } from 'node:process'; 4 | 5 | import { faker } from '@faker-js/faker'; 6 | import { createId } from '@paralleldrive/cuid2'; 7 | import { PrismaClient } from '@prisma/client'; 8 | 9 | const prettyPrint = (object: any) => 10 | console.log(JSON.stringify(object, undefined, 2)); 11 | 12 | const prisma = new PrismaClient(); 13 | 14 | const userClerkId = process.env.SEED_USER_CLERK_ID; 15 | const userEmail = process.env.SEED_USER_EMAIL; 16 | 17 | async function seed() { 18 | if (!userClerkId) { 19 | throw new Error('Please provide a userClerkId to seed.ts'); 20 | } 21 | 22 | if (!userEmail) { 23 | throw new Error('Please provide a userEmail to seed.ts'); 24 | } 25 | 26 | console.log('👤 Creating user profiles ...'); 27 | const user = await prisma.userProfile.create({ 28 | data: { 29 | clerkId: userClerkId, 30 | email: userEmail, 31 | id: createId(), 32 | name: faker.person.fullName(), 33 | acceptedTermsAndConditions: true, 34 | }, 35 | }); 36 | const memberUser = await prisma.userProfile.create({ 37 | data: { 38 | clerkId: createId(), 39 | email: faker.internet.email(), 40 | id: createId(), 41 | name: faker.person.fullName(), 42 | acceptedTermsAndConditions: true, 43 | }, 44 | }); 45 | const adminUser = await prisma.userProfile.create({ 46 | data: { 47 | clerkId: createId(), 48 | email: faker.internet.email(), 49 | id: createId(), 50 | name: faker.person.fullName(), 51 | acceptedTermsAndConditions: true, 52 | }, 53 | }); 54 | 55 | console.log('🏢 Creating organization ...'); 56 | const organizationName = faker.company.name(); 57 | const organization = await prisma.organization.create({ 58 | data: { 59 | name: organizationName, 60 | slug: faker.helpers.slugify(organizationName).toLowerCase(), 61 | }, 62 | }); 63 | 64 | console.log('👥 Adding users to organization ...'); 65 | await prisma.organization.update({ 66 | where: { id: organization.id }, 67 | data: { 68 | memberships: { 69 | create: [ 70 | { member: { connect: { id: user.id } }, role: 'owner' }, 71 | { member: { connect: { id: memberUser.id } }, role: 'member' }, 72 | { member: { connect: { id: adminUser.id } }, role: 'admin' }, 73 | ], 74 | }, 75 | }, 76 | }); 77 | 78 | console.log('========= 🌱 result of seed: ========='); 79 | prettyPrint({ user, organization, memberUser, adminUser }); 80 | } 81 | 82 | seed() 83 | .then(async () => { 84 | await prisma.$disconnect(); 85 | }) 86 | // eslint-disable-next-line unicorn/prefer-top-level-await 87 | .catch(async error => { 88 | console.error(error); 89 | await prisma.$disconnect(); 90 | exit(1); 91 | }); 92 | -------------------------------------------------------------------------------- /playwright/e2e/landing/landing.spec.ts: -------------------------------------------------------------------------------- 1 | import AxeBuilder from '@axe-core/playwright'; 2 | import { expect, test } from '@playwright/test'; 3 | 4 | import { deleteUserProfileFromDatabaseById } from '~/features/user-profile/user-profile-model.server'; 5 | import { teardownOrganizationAndMember } from '~/test/test-utils'; 6 | 7 | import { 8 | getPath, 9 | loginAndSaveUserProfileToDatabase, 10 | setupOrganizationAndLoginAsMember, 11 | } from '../../utils'; 12 | 13 | test.describe('landing page', () => { 14 | test('given a logged out user: the page shows the landing page content', async ({ 15 | page, 16 | }) => { 17 | // Playwright tests kept timing out due to loading image assets 18 | await page.route('**/*', route => { 19 | const resourceType = route.request().resourceType(); 20 | if (['image'].includes(resourceType)) { 21 | route.abort(); 22 | } else { 23 | route.continue(); 24 | } 25 | }); 26 | 27 | await page.goto('/'); 28 | 29 | // Page has the correct title and heading. 30 | expect(await page.title()).toMatch(/french house stack/i); 31 | await expect( 32 | page.getByRole('heading', { level: 1, name: /french house stack/i }), 33 | ).toBeVisible(); 34 | }); 35 | 36 | test('given a logged in user that is NOT onboarded: redirects the user to the onboarding page', async ({ 37 | page, 38 | }) => { 39 | const { id } = await loginAndSaveUserProfileToDatabase({ name: '', page }); 40 | 41 | await page.goto('/'); 42 | expect(getPath(page)).toEqual('/onboarding/user-profile'); 43 | 44 | await page.close(); 45 | await deleteUserProfileFromDatabaseById(id); 46 | }); 47 | 48 | test("given an onboarded user: redirects the user to their first organization's page", async ({ 49 | page, 50 | }) => { 51 | const { user, organization } = await setupOrganizationAndLoginAsMember({ 52 | page, 53 | }); 54 | 55 | await page.goto('/'); 56 | expect(getPath(page)).toEqual(`/organizations/${organization.slug}/home`); 57 | 58 | await page.close(); 59 | await teardownOrganizationAndMember({ organization, user }); 60 | }); 61 | 62 | test('page should lack any automatically detectable accessibility issues', async ({ 63 | page, 64 | }) => { 65 | // Playwright tests kept timing out due to loading image assets 66 | await page.route('**/*', route => { 67 | const resourceType = route.request().resourceType(); 68 | if (['image'].includes(resourceType)) { 69 | route.abort(); 70 | } else { 71 | route.continue(); 72 | } 73 | }); 74 | 75 | await page.goto('/'); 76 | 77 | const accessibilityScanResults = await new AxeBuilder({ page }) 78 | .disableRules(['color-contrast']) 79 | .analyze(); 80 | 81 | expect(accessibilityScanResults.violations).toEqual([]); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /app/routes/organizations_.$organizationSlug.settings.team-members.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ActionFunctionArgs, 3 | LoaderFunctionArgs, 4 | MetaFunction, 5 | } from '@remix-run/node'; 6 | import { useLoaderData } from '@remix-run/react'; 7 | 8 | import { useTranslation } from '~/features/localization/use-translation'; 9 | import { organizationTeamMembersAction } from '~/features/organizations/organizations-actions.server'; 10 | import { organizationSettingsTeamMembersLoader } from '~/features/organizations/organizations-loaders.server'; 11 | import { TeamMembersInviteLinkCardComponent } from '~/features/organizations/team-members-invite-link-card-component'; 12 | import { TeamMembersListCardComponent } from '~/features/organizations/team-members-list-card-component'; 13 | import { cn } from '~/utils/shadcn-ui'; 14 | 15 | export const handle = { i18n: ['pagination', 'organization-team-members'] }; 16 | 17 | export async function loader({ request, params }: LoaderFunctionArgs) { 18 | return await organizationSettingsTeamMembersLoader({ request, params }); 19 | } 20 | 21 | export const meta: MetaFunction = ({ data }) => [ 22 | { title: data?.pageTitle || 'Organization Profile' }, 23 | ]; 24 | 25 | export async function action({ request, params }: ActionFunctionArgs) { 26 | return await organizationTeamMembersAction({ request, params }); 27 | } 28 | 29 | export default function OrganizationSettingsTeamMembers() { 30 | const { t } = useTranslation('organization-team-members'); 31 | const loaderData = useLoaderData(); 32 | 33 | return ( 34 | <> 35 |
36 |

{t('team-members')}

37 | 38 | {loaderData.currentUserIsOwner && ( 39 |
40 | 43 |
44 | )} 45 | 46 |
53 | ({ 59 | ...member, 60 | deactivatedAt: 61 | member.deactivatedAt === null 62 | ? // eslint-disable-next-line unicorn/no-null 63 | null 64 | : new Date(member.deactivatedAt), 65 | }))} 66 | /> 67 |
68 |
69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /app/database.server.ts: -------------------------------------------------------------------------------- 1 | import { init } from '@paralleldrive/cuid2'; 2 | import type { Prisma } from '@prisma/client'; 3 | import { PrismaClient } from '@prisma/client'; 4 | 5 | let prisma: PrismaClient; 6 | 7 | declare global { 8 | var __database__: PrismaClient; 9 | } 10 | 11 | const cuid = init({ length: 8 }); 12 | 13 | /** 14 | * Middleware to avoid collisions when setting a slug. It autogenerates a slug 15 | * with cuid, which should pretty much never collide. 16 | * 17 | * @param parameters - An object with information about the action (e.g. name 18 | * of the query). 19 | * @param next - next represents the "next level" in the middleware stack, which 20 | * could be the next middleware or the Prisma Query, depending on where in the 21 | * stack you are. 22 | * 23 | * @see https://www.prisma.io/docs/concepts/components/prisma-client/middleware 24 | * @see https://www.prisma.io/docs/concepts/components/prisma-client/middleware#running-order-and-the-middleware-stack 25 | * 26 | * @returns The result of the next middleware in the stack. 27 | */ 28 | const slugMiddleware: Prisma.Middleware = async (parameters, next) => { 29 | if (parameters.action === 'create' && parameters.model === 'Organization') { 30 | const { 31 | args: { data }, 32 | } = parameters; 33 | const slugExists = await prisma.organization.findUnique({ 34 | where: { slug: data.slug }, 35 | }); 36 | 37 | // If the slug exists, or the slug is a hardcoded route we need to generate 38 | // a new slug. 39 | if (slugExists || data.slug === 'new') { 40 | data.slug = (data.slug + '-' + cuid()).toLowerCase(); 41 | } 42 | } 43 | 44 | if (parameters.action === 'update' && parameters.model === 'Organization') { 45 | const { 46 | args: { data, where }, 47 | } = parameters; 48 | if (data.slug) { 49 | const slugExists = await prisma.organization.findUnique({ 50 | where: { slug: data.slug }, 51 | }); 52 | 53 | // If the slug exists, and it's not the slug of the organization we're 54 | // updating, or the slug is a hardcoded route then we need to generate a 55 | // new slug. 56 | if ((slugExists && slugExists.id !== where.id) || data.slug === 'new') { 57 | data.slug = (data.slug + '-' + cuid()).toLowerCase(); 58 | } 59 | } 60 | } 61 | 62 | const result = await next(parameters); 63 | return result; 64 | }; 65 | 66 | // This is needed because in development we don't want to restart 67 | // the server with every change, but we want to make sure we don't 68 | // create a new connection to the DB with every change either. 69 | // In production we'll have a single connection to the DB. 70 | if (process.env.NODE_ENV === 'production') { 71 | prisma = new PrismaClient(); 72 | 73 | prisma.$use(slugMiddleware); 74 | } else { 75 | if (!global.__database__) { 76 | global.__database__ = new PrismaClient(); 77 | 78 | global.__database__.$use(slugMiddleware); 79 | } 80 | prisma = global.__database__; 81 | prisma.$connect(); 82 | } 83 | 84 | export { prisma }; 85 | -------------------------------------------------------------------------------- /app/features/organizations/invite-link-uses-model.server.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InviteLinkUse, 3 | OrganizationInviteLink, 4 | UserProfile, 5 | } from '@prisma/client'; 6 | 7 | import { prisma } from '~/database.server'; 8 | 9 | export type PartialinviteLinkUseParameters = Pick< 10 | Parameters[0]['data'], 11 | 'id' 12 | >; 13 | 14 | // CREATE 15 | 16 | /** 17 | * Saves a new Invite Link Uses to the database. 18 | * 19 | * @param Invite Link Uses - Parameters of the Invite Link Uses that should be created. 20 | * @returns The newly created Invite Link Uses. 21 | */ 22 | export async function saveInviteLinkUseToDatabase( 23 | inviteLinkUse: PartialinviteLinkUseParameters & { 24 | inviteLinkId: OrganizationInviteLink['id']; 25 | userId: UserProfile['id']; 26 | }, 27 | ) { 28 | return prisma.inviteLinkUse.create({ data: inviteLinkUse }); 29 | } 30 | 31 | // READ 32 | 33 | /** 34 | * Retrieves a Invite Link Uses record from the database based on its id. 35 | * 36 | * @param id - The id of the Invite Link Uses to get. 37 | * @returns The Invite Link Uses with a given id or null if it wasn't found. 38 | */ 39 | export async function retrieveInviteLinkUseFromDatabaseById( 40 | id: InviteLinkUse['id'], 41 | ) { 42 | return prisma.inviteLinkUse.findUnique({ where: { id } }); 43 | } 44 | 45 | export async function retrieveinviteLinkUseFromDatabaseByUserIdAndLinkId({ 46 | inviteLinkId, 47 | userId, 48 | }: { 49 | inviteLinkId: OrganizationInviteLink['id']; 50 | userId: UserProfile['id']; 51 | }) { 52 | return prisma.inviteLinkUse.findUnique({ 53 | where: { inviteLinkId_userId: { inviteLinkId, userId } }, 54 | }); 55 | } 56 | 57 | // UPDATE 58 | 59 | type inviteLinkUseUpdateParameters = Parameters< 60 | typeof prisma.inviteLinkUse.update 61 | >[0]['data'] & { 62 | inviteLinkId: OrganizationInviteLink['id']; 63 | userId: UserProfile['id']; 64 | }; 65 | 66 | /** 67 | * Updates a Invite Link Uses in the database. 68 | * 69 | * @param options - A an object with the Invite Link Uses's id and the new values. 70 | * @returns The updated Invite Link Uses. 71 | */ 72 | export async function updateInviteLinkUseInDatabaseById({ 73 | id, 74 | inviteLinkUse, 75 | }: { 76 | /** 77 | * The id of the Invite Link Uses you want to update. 78 | */ 79 | id: InviteLinkUse['id']; 80 | /** 81 | * The values of the Invite Link Uses you want to change. 82 | */ 83 | inviteLinkUse: Partial>; 84 | }) { 85 | return prisma.inviteLinkUse.update({ where: { id }, data: inviteLinkUse }); 86 | } 87 | 88 | // DELETE 89 | 90 | /** 91 | * Removes a Invite Link Uses from the database. 92 | * 93 | * @param id - The id of the Invite Link Uses you want to delete. 94 | * @returns The Invite Link Uses that was deleted. 95 | */ 96 | export async function deleteInviteLinkUseFromDatabaseById( 97 | id: InviteLinkUse['id'], 98 | ) { 99 | return prisma.inviteLinkUse.delete({ where: { id } }); 100 | } 101 | -------------------------------------------------------------------------------- /playwright/e2e/user-authentication/login.spec.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import AxeBuilder from '@axe-core/playwright'; 4 | import { expect, test } from '@playwright/test'; 5 | 6 | import { createPopulatedUserProfile } from '~/features/user-profile/user-profile-factories.server'; 7 | import { teardownOrganizationAndMember } from '~/test/test-utils'; 8 | 9 | import { getPath, setupOrganizationAndLoginAsMember } from '../../utils'; 10 | 11 | test.describe('login page', () => { 12 | test("given an onboarded user: redirects to the user's first organization's home page", async ({ 13 | page, 14 | }) => { 15 | const { organization, user } = await setupOrganizationAndLoginAsMember({ 16 | page, 17 | }); 18 | 19 | await page.goto('/login'); 20 | expect(getPath(page)).toEqual(`/organizations/${organization.slug}/home`); 21 | 22 | await page.close(); 23 | await teardownOrganizationAndMember({ organization, user }); 24 | }); 25 | 26 | test('given a logged out user and entering invalid data: shows the correct error messages', async ({ 27 | page, 28 | }) => { 29 | await page.goto('/login'); 30 | 31 | // The user can fill in the form with invalid data and submit it. 32 | const loginButton = page.getByRole('button', { name: /login/i }); 33 | await loginButton.click(); 34 | 35 | // The email input shows the correct error messages. 36 | await expect(page.getByText(/please enter a valid email/i)).toBeVisible(); 37 | 38 | // Invalid email. 39 | const emailInput = page.getByLabel(/email/i); 40 | await emailInput.fill('invalid email'); 41 | await loginButton.click(); 42 | await expect( 43 | page.getByText(/a valid email consists of characters, '@' and '.'./i), 44 | ).toBeVisible(); 45 | 46 | // User does not exist. 47 | await emailInput.fill(createPopulatedUserProfile().email); 48 | await loginButton.click(); 49 | await expect( 50 | page.getByText( 51 | /user with given email doesn't exist. did you mean to create a new account instead?/i, 52 | ), 53 | ).toBeVisible(); 54 | 55 | // The register button has the correct link. 56 | await expect( 57 | page.getByRole('link', { name: /create your account/i }), 58 | ).toHaveAttribute('href', '/register'); 59 | }); 60 | 61 | test('given a logged out user and an invite link token in the url: changes the register redirect button to also include the token', async ({ 62 | page, 63 | }) => { 64 | // Navigate to the login page with a token. 65 | await page.goto(`/login?token=1234`); 66 | 67 | // The register button has the correct link. 68 | await expect( 69 | page.getByRole('link', { name: /create your account/i }), 70 | ).toHaveAttribute('href', '/register?token=1234'); 71 | }); 72 | 73 | test('page should lack any automatically detectable accessibility issues', async ({ 74 | page, 75 | }) => { 76 | await page.goto('/login'); 77 | 78 | const accessibilityScanResults = await new AxeBuilder({ page }) 79 | .disableRules('color-contrast') 80 | .analyze(); 81 | 82 | expect(accessibilityScanResults.violations).toEqual([]); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /app/features/user-profile/user-profile-helpers.server.ts: -------------------------------------------------------------------------------- 1 | import type { UserProfile } from '@prisma/client'; 2 | import { not } from 'ramda'; 3 | 4 | import { asyncPipe } from '~/utils/async-pipe'; 5 | 6 | import { 7 | logout, 8 | requireUserIsAuthenticated, 9 | } from '../user-authentication/user-authentication-helpers.server'; 10 | import { 11 | retrieveFirstUserProfileFromDatabaseByEmail, 12 | retrieveUserProfileWithMembershipsFromDatabaseById, 13 | } from './user-profile-model.server'; 14 | 15 | /** 16 | * Returns a boolean whether a user profile with the given email exists. 17 | * 18 | * @param email - The email to check for. 19 | * @returns A promise that resolves with boolean whether a user profile with the 20 | * given email exists. 21 | */ 22 | export const getDoesUserProfileExistByEmail = asyncPipe( 23 | retrieveFirstUserProfileFromDatabaseByEmail, 24 | Boolean, 25 | ); 26 | 27 | /** 28 | * Returns a boolean indicating whether a user profile with the given email does 29 | * NOT exist. 30 | * 31 | * @param email - The email to check for. 32 | * @returns A promise that resolves with boolean indicating whether a user 33 | * profile with the given email does NOT exist. 34 | */ 35 | export const getIsEmailAvailableForRegistration = asyncPipe( 36 | getDoesUserProfileExistByEmail, 37 | not, 38 | ); 39 | 40 | /** 41 | * Ensures that a user profile is present. 42 | * 43 | * @param user profile - The user profile to check - possibly null or undefined. 44 | * @returns The same user profile if it exists. 45 | * @throws Logs the user out if the user profile is missing. 46 | */ 47 | export const throwIfUserProfileIsMissing = async ( 48 | request: Request, 49 | userProfile: T | null, 50 | ) => { 51 | if (!userProfile) { 52 | throw await logout(request, '/login'); 53 | } 54 | 55 | return userProfile; 56 | }; 57 | 58 | /** 59 | * Ensures the user exists and has a valid profile. 60 | * 61 | * @param request - The Request object containing the user's request. 62 | * @returns The user object if the user exists and has a valid profile. 63 | * @throws A Response with the appropriate error status if the user is not 64 | * authenticated or missing a profile. 65 | */ 66 | export async function requireUserExists(request: Request) { 67 | const userId = await requireUserIsAuthenticated(request); 68 | const user = await retrieveUserProfileWithMembershipsFromDatabaseById(userId); 69 | 70 | return throwIfUserProfileIsMissing(request, user); 71 | } 72 | 73 | type getNameAbbreviation = (userProfile: UserProfile['name']) => string; 74 | 75 | /** 76 | * Generates an uppercased abbreviation of a user's name. 77 | * 78 | * @param userProfile - The `UserProfile` object containing the user's name. 79 | * @returns The abbreviated name as a string. If the name is not provided, an 80 | * empty string is returned. 81 | */ 82 | export const getNameAbbreviation: getNameAbbreviation = name => { 83 | const parts = name.trim().split(' '); 84 | return parts.length === 0 85 | ? '' 86 | : parts.length === 1 87 | ? parts[0].slice(0, 1).toUpperCase() 88 | : `${parts[0].slice(0, 1)}${parts.at(-1)!.slice(0, 1)}`.toUpperCase(); 89 | }; 90 | -------------------------------------------------------------------------------- /app/utils/get-search-parameter-from-request.server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { 4 | getSearchParameterFromRequest, 5 | getSearchParameterFromUrl, 6 | requestToUrl, 7 | } from './get-search-parameter-from-request.server'; 8 | 9 | describe('requestToUrl()', () => { 10 | test('given a request with a url: returns a URL object for it', () => { 11 | const url = 'https://www.mozilla.org/favicon.ico'; 12 | 13 | const actual = requestToUrl(new Request(url)); 14 | const expected = new URL(url); 15 | 16 | expect(actual).toEqual(expected); 17 | }); 18 | }); 19 | 20 | describe('getSearchParameterFromUrl()', () => { 21 | test('given a url and a search parameter that is in the url: returns the value of the search parameter', () => { 22 | const searchParameter = 'redirectTo'; 23 | const url = new URL(`https://example.com?${searchParameter}=home&foo=bar`); 24 | 25 | const actual = getSearchParameterFromUrl(searchParameter)(url); 26 | const expected = 'home'; 27 | 28 | expect(actual).toEqual(expected); 29 | }); 30 | 31 | test('given a url and a search parameter that is NOT in the url: returns null', () => { 32 | const searchParameter = 'search'; 33 | const url = new URL(`https://example.com?foo=bar`); 34 | 35 | const actual = getSearchParameterFromUrl(searchParameter)(url); 36 | const expected = null; 37 | 38 | expect(actual).toEqual(expected); 39 | }); 40 | }); 41 | 42 | describe('getSearchParameterFromRequest()', () => { 43 | test('given a request and a search parameter that is in the request url: returns the value of the search parameter', () => { 44 | const searchParameter = 'redirectTo'; 45 | const request = new Request( 46 | `https://example.com?${searchParameter}=home&foo=bar`, 47 | ); 48 | 49 | const actual = getSearchParameterFromRequest(searchParameter)(request); 50 | const expected = 'home'; 51 | 52 | expect(actual).toEqual(expected); 53 | }); 54 | 55 | test('given a request and a search parameter that is NOT in the request url: returns null', () => { 56 | const searchParameter = 'filterUsers'; 57 | const request = new Request(`https://example.com?foo=bar`); 58 | 59 | const actual = getSearchParameterFromRequest(searchParameter)(request); 60 | const expected = null; 61 | 62 | expect(actual).toEqual(expected); 63 | }); 64 | 65 | test("given a request and a search parameter that is in the request's url: returns the value of the search parameter", () => { 66 | const searchParameter = 'redirectTo'; 67 | const request = new Request( 68 | `https://example.com?${searchParameter}=home&foo=bar`, 69 | ); 70 | 71 | const actual = getSearchParameterFromRequest(searchParameter)(request); 72 | const expected = 'home'; 73 | 74 | expect(actual).toEqual(expected); 75 | }); 76 | 77 | test("given a request and a search parameter that is NOT in the request's url: returns null", () => { 78 | const searchParameter = 'filterUsers'; 79 | const request = new Request(`https://example.com?foo=bar`); 80 | 81 | const actual = getSearchParameterFromRequest(searchParameter)(request); 82 | const expected = null; 83 | 84 | expect(actual).toEqual(expected); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /app/features/organizations/organizations-sidebar-component.tsx: -------------------------------------------------------------------------------- 1 | import type { UIMatch } from '@remix-run/react'; 2 | import { useMatches } from '@remix-run/react'; 3 | import { HomeIcon, SettingsIcon } from 'lucide-react'; 4 | import { findLast, has, pipe, prop } from 'ramda'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | import type { HeaderUserProfileDropDownProps } from '~/components/header'; 8 | import { Sidebar } from '~/components/sidebar'; 9 | 10 | import type { OrganizationsSwitcherComponentProps } from './organizations-switcher-component'; 11 | import { OrganizationsSwitcherComponent } from './organizations-switcher-component'; 12 | 13 | type AppData = { 14 | headerTitle?: string; 15 | renderSearchBar?: boolean; 16 | renderBackButton?: boolean; 17 | renderStaticSidebar?: boolean; 18 | }; 19 | type RouteMatch = UIMatch; 20 | 21 | export const findHeaderTitle = (matches: RouteMatch[]) => 22 | findLast(pipe(prop('data'), has('headerTitle')), matches)?.data?.headerTitle; 23 | 24 | export const findRenderSearchBar = (matches: RouteMatch[]) => 25 | findLast(pipe(prop('data'), has('renderSearchBar')), matches)?.data 26 | ?.renderSearchBar; 27 | 28 | export const findRenderBackButton = (matches: RouteMatch[]) => 29 | findLast(pipe(prop('data'), has('renderBackButton')), matches)?.data 30 | ?.renderBackButton; 31 | 32 | export const findRenderStaticSidebar = (matches: RouteMatch[]) => 33 | findLast(pipe(prop('data'), has('renderStaticSidebar')), matches)?.data 34 | ?.renderStaticSidebar; 35 | 36 | export type OrganizationsSideBarComponentProps = { 37 | organizationSlug: string; 38 | userNavigation: HeaderUserProfileDropDownProps; 39 | } & Pick; 40 | 41 | export function OrganizationsSidebarComponent({ 42 | organizations, 43 | organizationSlug, 44 | userNavigation, 45 | }: OrganizationsSideBarComponentProps) { 46 | const { t } = useTranslation('organizations'); 47 | const matches = useMatches() as RouteMatch[]; 48 | 49 | // All of the settings below can be set using the `loader` function. 50 | const headerTitle = findHeaderTitle(matches); 51 | const renderSearchBar = findRenderSearchBar(matches); 52 | const renderBackButton = findRenderBackButton(matches); 53 | const renderStaticSidebar = findRenderStaticSidebar(matches); 54 | 55 | return ( 56 | 59 | 60 |
61 | } 62 | headerTitle={headerTitle} 63 | navigation={[ 64 | { 65 | id: 'app', 66 | items: [ 67 | { 68 | name: t('home'), 69 | href: `/organizations/${organizationSlug}/home`, 70 | icon: HomeIcon, 71 | }, 72 | { 73 | name: t('settings'), 74 | href: `/organizations/${organizationSlug}/settings`, 75 | icon: SettingsIcon, 76 | }, 77 | ], 78 | }, 79 | ]} 80 | renderBackButton={renderBackButton} 81 | renderSearchBar={renderSearchBar} 82 | renderStaticSidebar={renderStaticSidebar} 83 | userNavigation={userNavigation} 84 | /> 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /app/features/organizations/accept-membership-invite-page-component.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from '@remix-run/react'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | import { Button } from '~/components/ui/button'; 5 | 6 | export type AcceptMembershipInvitePageComponentProps = { 7 | inviterName: string; 8 | organizationName: string; 9 | }; 10 | 11 | export function AcceptMembershipInvitePageComponent({ 12 | inviterName, 13 | organizationName, 14 | }: AcceptMembershipInvitePageComponentProps) { 15 | const { t } = useTranslation('accept-membership-invite'); 16 | 17 | return ( 18 |
19 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /playwright/e2e/organizations/organizations-slug.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | import { createPopulatedOrganization } from '~/features/organizations/organizations-factories.server'; 4 | import { 5 | deleteOrganizationFromDatabaseById, 6 | saveOrganizationToDatabase, 7 | } from '~/features/organizations/organizations-model.server'; 8 | import { deleteUserProfileFromDatabaseById } from '~/features/user-profile/user-profile-model.server'; 9 | import { teardownOrganizationAndMember } from '~/test/test-utils'; 10 | 11 | import { 12 | getPath, 13 | loginAndSaveUserProfileToDatabase, 14 | setupOrganizationAndLoginAsMember, 15 | } from '../../utils'; 16 | 17 | test.describe('organizations slug page', () => { 18 | test('given a logged out user: redirects the user to the login page', async ({ 19 | page, 20 | }) => { 21 | const { slug } = createPopulatedOrganization(); 22 | await page.goto(`/organizations/${slug}`); 23 | const searchParams = new URLSearchParams(); 24 | searchParams.append('redirectTo', `/organizations/${slug}/home`); 25 | expect(getPath(page)).toEqual(`/login?${searchParams.toString()}`); 26 | }); 27 | 28 | test('given a logged in user who is NOT onboarded: redirects the user to the onboarding page', async ({ 29 | page, 30 | }) => { 31 | const { slug } = createPopulatedOrganization(); 32 | const user = await loginAndSaveUserProfileToDatabase({ name: '', page }); 33 | 34 | await page.goto(`/organizations/${slug}`); 35 | expect(getPath(page)).toEqual('/onboarding/user-profile'); 36 | 37 | await deleteUserProfileFromDatabaseById(user.id); 38 | }); 39 | 40 | test('given a logged in user who is onboarded and a member of the organization with the given slug: redirects the user to the home page of the organization', async ({ 41 | page, 42 | }) => { 43 | const { organization, user } = await setupOrganizationAndLoginAsMember({ 44 | page, 45 | }); 46 | 47 | await page.goto(`/organizations/${organization.slug}`); 48 | expect(getPath(page)).toEqual(`/organizations/${organization.slug}/home`); 49 | 50 | await teardownOrganizationAndMember({ organization, user }); 51 | }); 52 | 53 | test('given a logged in user who is onboarded and NOT a member of the organization with the given slug: shows a 404 not found page', async ({ 54 | page, 55 | }) => { 56 | const { organization, user } = await setupOrganizationAndLoginAsMember({ 57 | page, 58 | }); 59 | const otherOrganization = createPopulatedOrganization(); 60 | await saveOrganizationToDatabase(otherOrganization); 61 | 62 | await page.goto(`/organizations/${otherOrganization.slug}`); 63 | 64 | expect(await page.title()).toEqual('404 | French House Stack'); 65 | await expect( 66 | page.getByRole('heading', { name: /page not found/i, level: 1 }), 67 | ).toBeVisible(); 68 | await expect(page.getByText(/404/i)).toBeVisible(); 69 | await expect( 70 | page.getByText(/sorry, we couldn't find the page you're looking for/i), 71 | ).toBeVisible(); 72 | await expect( 73 | page.getByRole('link', { name: /go back home/i }), 74 | ).toHaveAttribute('href', '/'); 75 | 76 | await teardownOrganizationAndMember({ organization, user }); 77 | await deleteOrganizationFromDatabaseById(otherOrganization.id); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/prefer-module */ 2 | import { resolve } from 'node:path'; 3 | import { PassThrough } from 'node:stream'; 4 | 5 | import type { HandleDocumentRequestFunction } from '@remix-run/node'; 6 | import { createReadableStreamFromReadable } from '@remix-run/node'; 7 | import { RemixServer } from '@remix-run/react'; 8 | import { createInstance } from 'i18next'; 9 | import Backend from 'i18next-fs-backend'; 10 | import { isbot } from 'isbot'; 11 | import { renderToPipeableStream } from 'react-dom/server'; 12 | import { I18nextProvider, initReactI18next } from 'react-i18next'; 13 | 14 | import { i18n } from './features/localization/i18n'; 15 | import { i18next } from './features/localization/i18next.server'; 16 | 17 | if (process.env.SERVER_MOCKS === 'true') { 18 | // @ts-expect-error - global is readonly and for some reason MSW accesses it. 19 | global.location = { protocol: 'http', host: 'localhost' }; 20 | const { clerkHandlers } = await import('./test/mocks/handlers/clerk'); 21 | const { startMockServer } = await import('./test/mocks/server'); 22 | startMockServer([...clerkHandlers]); 23 | } 24 | 25 | if (process.env.NODE_ENV === 'production' && process.env.SENTRY_DSN) { 26 | const { initializeServerMonitoring } = await import( 27 | './features/monitoring/monitoring-helpers.server' 28 | ); 29 | initializeServerMonitoring(); 30 | } 31 | 32 | const ABORT_DELAY = 5000; 33 | 34 | const handleRequest: HandleDocumentRequestFunction = async ( 35 | request, 36 | responseStatusCode, 37 | responseHeaders, 38 | remixContext, 39 | ) => { 40 | const callbackName = isbot(request.headers.get('user-agent') || '') 41 | ? 'onAllReady' 42 | : 'onShellReady'; 43 | 44 | const instance = createInstance(); 45 | const lng = await i18next.getLocale(request); 46 | const ns = i18next.getRouteNamespaces(remixContext); 47 | 48 | await instance 49 | .use(initReactI18next) 50 | .use(Backend) 51 | .init({ 52 | ...i18n, 53 | lng, 54 | ns, 55 | backend: { 56 | loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'), 57 | }, 58 | }); 59 | 60 | return new Promise((resolve, reject) => { 61 | let didError = false; 62 | 63 | const { pipe, abort } = renderToPipeableStream( 64 | 65 | 66 | , 67 | { 68 | [callbackName]() { 69 | const body = new PassThrough(); 70 | 71 | responseHeaders.set('Content-Type', 'text/html'); 72 | 73 | resolve( 74 | new Response(createReadableStreamFromReadable(body), { 75 | headers: responseHeaders, 76 | status: didError ? 500 : responseStatusCode, 77 | }), 78 | ); 79 | 80 | pipe(body); 81 | }, 82 | onShellError(error) { 83 | reject(error); 84 | }, 85 | onError(error) { 86 | didError = true; 87 | 88 | console.error(error); 89 | }, 90 | }, 91 | ); 92 | 93 | setTimeout(abort, ABORT_DELAY); 94 | }); 95 | }; 96 | 97 | export default handleRequest; 98 | 99 | export { wrapRemixHandleError as handleError } from '@sentry/remix'; 100 | -------------------------------------------------------------------------------- /app/features/settings/settings-helpers.server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | 3 | import { createUserWithOrganizations } from '~/test/test-utils'; 4 | 5 | import type { OnboardingUser } from '../onboarding/onboarding-helpers.server'; 6 | import { ORGANIZATION_MEMBERSHIP_ROLES } from '../organizations/organizations-constants'; 7 | import { createPopulatedOrganization } from '../organizations/organizations-factories.server'; 8 | import { 9 | getUsersOwnedOrganizations, 10 | mapUserDataToSettingsProps, 11 | } from './settings-helpers.server'; 12 | 13 | describe('mapUserDataToSettingsProps()', () => { 14 | test('given a user: returns the correct new organization props', async () => { 15 | const user = createUserWithOrganizations({ name: 'Phoenix Guerrero' }); 16 | 17 | const actual = mapUserDataToSettingsProps({ user }); 18 | const expected = { 19 | userNavigation: { 20 | abbreviation: 'PG', 21 | email: user.email, 22 | name: user.name, 23 | items: [], 24 | }, 25 | user, 26 | }; 27 | 28 | expect(actual).toEqual(expected); 29 | }); 30 | }); 31 | 32 | describe('getUsersOwnedOrganizations()', () => { 33 | test('given a user with no organizations: returns an empty array', async () => { 34 | const user = createUserWithOrganizations({ memberships: [] }); 35 | 36 | const actual = getUsersOwnedOrganizations(user); 37 | const expected: OnboardingUser['memberships'][number]['organization'][] = 38 | []; 39 | 40 | expect(actual).toEqual(expected); 41 | }); 42 | 43 | test("given a user with organizations, but they're never an owner: returns an empty array", async () => { 44 | const user = createUserWithOrganizations({ 45 | memberships: [ 46 | { 47 | role: ORGANIZATION_MEMBERSHIP_ROLES.ADMIN, 48 | organization: createPopulatedOrganization(), 49 | deactivatedAt: null, 50 | }, 51 | { 52 | role: ORGANIZATION_MEMBERSHIP_ROLES.MEMBER, 53 | organization: createPopulatedOrganization(), 54 | deactivatedAt: null, 55 | }, 56 | ], 57 | }); 58 | 59 | const actual = getUsersOwnedOrganizations(user); 60 | const expected: OnboardingUser['memberships'][number]['organization'][] = 61 | []; 62 | 63 | expect(actual).toEqual(expected); 64 | }); 65 | 66 | test("given a user with organizations, and they're the owner of some of them: returns the ones the user is an owner of", async () => { 67 | const ownedOrganization = createPopulatedOrganization(); 68 | const user = createUserWithOrganizations({ 69 | memberships: [ 70 | { 71 | role: ORGANIZATION_MEMBERSHIP_ROLES.MEMBER, 72 | organization: createPopulatedOrganization(), 73 | deactivatedAt: null, 74 | }, 75 | { 76 | role: ORGANIZATION_MEMBERSHIP_ROLES.OWNER, 77 | organization: ownedOrganization, 78 | deactivatedAt: null, 79 | }, 80 | { 81 | role: ORGANIZATION_MEMBERSHIP_ROLES.MEMBER, 82 | organization: createPopulatedOrganization(), 83 | deactivatedAt: null, 84 | }, 85 | ], 86 | }); 87 | 88 | const actual = getUsersOwnedOrganizations(user); 89 | const expected = [ownedOrganization]; 90 | 91 | expect(actual).toEqual(expected); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './playwright', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: 1, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | baseURL: process.env.BASE_URL || 'http://localhost:3000', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: 'chromium', 37 | use: { 38 | ...devices['Desktop Chrome'], 39 | launchOptions: { 40 | args: [ 41 | '--allow-file-access-from-files', 42 | '--use-fake-ui-for-media-stream', 43 | '--use-fake-device-for-media-stream', 44 | '--use-file-for-fake-audio-capture=playwright/fixtures/sample.wav', 45 | ], 46 | }, 47 | }, 48 | }, 49 | 50 | { 51 | name: 'firefox', 52 | use: { 53 | ...devices['Desktop Firefox'], 54 | }, 55 | }, 56 | 57 | { 58 | name: 'webkit', 59 | use: { 60 | ...devices['Desktop Safari'], 61 | }, 62 | }, 63 | 64 | /* Test against mobile viewports. */ 65 | { 66 | name: 'Mobile Chrome', 67 | use: { 68 | ...devices['Pixel 5'], 69 | launchOptions: { 70 | args: [ 71 | '--allow-file-access-from-files', 72 | '--use-fake-ui-for-media-stream', 73 | '--use-fake-device-for-media-stream', 74 | '--use-file-for-fake-audio-capture=playwright/fixtures/sample.wav', 75 | ], 76 | }, 77 | }, 78 | }, 79 | { 80 | name: 'Mobile Safari', 81 | use: { 82 | ...devices['iPhone 12'], 83 | }, 84 | }, 85 | 86 | /* Test against branded browsers. */ 87 | // { 88 | // name: 'Microsoft Edge', 89 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 90 | // }, 91 | // { 92 | // name: 'Google Chrome', 93 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 94 | // }, 95 | ], 96 | 97 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 98 | // outputDir: 'test-results/', 99 | 100 | /* Run your local dev server before starting the tests */ 101 | webServer: { 102 | command: process.env.CI 103 | ? 'npm run build && npm run start-with-server-mocks' 104 | : 'npm run dev-with-server-mocks', 105 | port: 3000, 106 | }, 107 | }); 108 | -------------------------------------------------------------------------------- /app/utils/async-pipe.ts: -------------------------------------------------------------------------------- 1 | type Callback = (a: any) => MaybePromise; 2 | type FunToReturnType = F extends Callback 3 | ? ReturnType extends Promise 4 | ? U 5 | : ReturnType 6 | : never; 7 | type EmptyPipe = (a: never) => Promise; 8 | 9 | type AsyncPipeReturnType< 10 | FS extends Callback[], 11 | P = Parameters[0], 12 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 13 | > = FS extends [...infer _, infer Last] 14 | ? (a: P) => Promise> 15 | : EmptyPipe; 16 | 17 | type AsyncParameters< 18 | FS extends Callback[], 19 | P = Parameters[0], 20 | > = FS extends [infer H, ...infer Rest] 21 | ? H extends (p: P) => unknown 22 | ? Rest extends Callback[] 23 | ? [H, ...AsyncParameters>] 24 | : [{ error: '__A_PARAMETER_NOT_A_FUNCTION__' }, ...Rest] 25 | : [ 26 | { error: '__INCORRECT_FUNCTION__'; provided: H; expected_parameter: P }, 27 | ...Rest, 28 | ] 29 | : FS; 30 | 31 | type MaybePromise = T | Promise; 32 | 33 | export function asyncPipe( 34 | ab: (a: A) => MaybePromise, 35 | ): (a: A) => Promise; 36 | export function asyncPipe( 37 | ab: (a: A) => MaybePromise, 38 | bc: (b: B) => MaybePromise, 39 | ): (a: A) => Promise; 40 | export function asyncPipe( 41 | ab: (a: A) => MaybePromise, 42 | bc: (b: B) => MaybePromise, 43 | cd: (c: C) => MaybePromise, 44 | ): (a: A) => Promise; 45 | export function asyncPipe( 46 | ab: (a: A) => MaybePromise, 47 | bc: (b: B) => MaybePromise, 48 | cd: (c: C) => MaybePromise, 49 | de: (d: D) => MaybePromise, 50 | ): (a: A) => Promise; 51 | export function asyncPipe( 52 | ab: (a: A) => MaybePromise, 53 | bc: (b: B) => MaybePromise, 54 | cd: (c: C) => MaybePromise, 55 | de: (d: D) => MaybePromise, 56 | // eslint-disable-next-line unicorn/prevent-abbreviations 57 | ef: (e: E) => MaybePromise, 58 | ): (a: A) => Promise; 59 | export function asyncPipe( 60 | ab: (a: A) => MaybePromise, 61 | bc: (b: B) => MaybePromise, 62 | cd: (c: C) => MaybePromise, 63 | de: (d: D) => MaybePromise, 64 | // eslint-disable-next-line unicorn/prevent-abbreviations 65 | ef: (e: E) => MaybePromise, 66 | fg: (f: F) => MaybePromise, 67 | ): (a: A) => Promise; 68 | export function asyncPipe( 69 | ab: (a: A) => MaybePromise, 70 | bc: (b: B) => MaybePromise, 71 | cd: (c: C) => MaybePromise, 72 | de: (d: D) => MaybePromise, 73 | // eslint-disable-next-line unicorn/prevent-abbreviations 74 | ef: (e: E) => MaybePromise, 75 | fg: (f: F) => MaybePromise, 76 | gh: (g: G) => MaybePromise, 77 | ): (a: A) => Promise; 78 | 79 | /** 80 | * Composes functions which can, but don't have to, return promises. 81 | * 82 | * @param fns - The functions to compose. 83 | * @returns A function that takes an argument and returns a promise. 84 | */ 85 | export function asyncPipe( 86 | ...fns: AsyncParameters 87 | ): AsyncPipeReturnType; 88 | export function asyncPipe(...fns: AsyncParameters) { 89 | if (fns.length === 0) return () => Promise.resolve(); 90 | // eslint-disable-next-line prettier/prettier 91 | return (x: Parameters<(typeof fns)[0]>[0]) => 92 | fns.reduce(async (y, function_) => function_(await y), x); 93 | } 94 | --------------------------------------------------------------------------------