create-m5kdev 0.21.6 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-m5kdev",
3
- "version": "0.21.6",
3
+ "version": "0.25.0",
4
4
  "license": "GPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,5 +26,9 @@
26
26
  - Keep the app shell shape: `NuqsAdapter` + `BrowserRouter` + `Providers`.
27
27
  - Compose global providers only in `apps/webapp/src/Providers.tsx`.
28
28
  - Use `AppConfigProvider` and `AppTrpcQueryProvider` from `@m5kdev/frontend`; use `@m5kdev/web-ui` for web-only UI.
29
+ - Define app roles in `apps/shared/src/modules/app/roles.constants.ts` and pass `roles: APP_ROLES_CONFIG` through `AppConfigProvider` and `createBackendApp` metadata.
30
+ - Add role display labels in `apps/webapp/translations/app.json` using keys like `organization.role.editor` (resolved as `app:organization.role.editor`).
31
+ - When adding custom roles, also extend module grants (for example `*.grants.ts`) so permissions match the new role keys.
32
+ - For upgrading existing apps, see the docs site guide `apps/docs/docs/guides/custom-app-roles-migration.md`.
29
33
  - Use `nuqs` for URL state, React Router for routing, HeroUI for UI primitives, and Tailwind v4 for styling.
30
34
  - Prefer shared framework utilities from `@m5kdev/frontend` and `@m5kdev/web-ui` before adding local duplicates.
@@ -10,11 +10,14 @@
10
10
  "email:dev": "react-email dev --dir src/emails"
11
11
  },
12
12
  "dependencies": {
13
+ "@m5kdev/commons": "workspace:*",
14
+ "@m5kdev/email": "workspace:*",
13
15
  "@react-email/components": "catalog:",
14
16
  "react": "catalog:",
15
17
  "react-dom": "catalog:"
16
18
  },
17
19
  "devDependencies": {
20
+ "@m5kdev/backend": "workspace:*",
18
21
  "@m5kdev/config": "catalog:",
19
22
  "@types/node": "catalog:",
20
23
  "@types/react": "catalog:",
@@ -23,5 +26,9 @@
23
26
  "tslib": "catalog:",
24
27
  "typescript": "catalog:"
25
28
  },
26
- "main": "./src/index.ts"
29
+ "main": "./src/index.ts",
30
+ "exports": {
31
+ ".": "./src/index.ts",
32
+ "./resources": "./src/resources.ts"
33
+ }
27
34
  }
@@ -1,30 +1,34 @@
1
- import { Text } from "@react-email/components";
2
- import { BaseEmail } from "../components/BaseEmail";
1
+ import { Heading, Text } from "@react-email/components";
2
+ import { CtaButton } from "@m5kdev/email/components/CtaButton";
3
+ import { EmailLayout } from "@m5kdev/email/components/EmailLayout";
4
+ import type { Brand, EmailTranslateFn } from "@m5kdev/email/types";
5
+
6
+ function resolveT(t?: EmailTranslateFn): EmailTranslateFn {
7
+ return t ?? ((key: string) => key);
8
+ }
3
9
 
4
10
  export default function AccountDeletionEmail({
5
- previewText = "Confirm your account deletion",
11
+ previewText,
6
12
  url,
13
+ brand,
14
+ t,
15
+ htmlLang,
7
16
  }: {
8
- previewText?: string;
17
+ previewText: string;
9
18
  url: string;
19
+ brand: Brand;
20
+ t?: EmailTranslateFn;
21
+ htmlLang?: string;
10
22
  }) {
23
+ const translate = resolveT(t);
24
+
11
25
  return (
12
- <BaseEmail
13
- previewText={previewText}
14
- eyebrow="Account Safety"
15
- title="Confirm account deletion"
16
- ctaLabel="Review deletion"
17
- ctaUrl={url}
18
- body={
19
- <>
20
- <Text style={{ margin: "0" }}>
21
- We received a request to delete your {{APP_NAME}} account.
22
- </Text>
23
- <Text style={{ margin: "0" }}>
24
- If that was you, use the link below to confirm the action.
25
- </Text>
26
- </>
27
- }
28
- />
26
+ <EmailLayout previewText={previewText} brand={brand} htmlLang={htmlLang}>
27
+ <Heading className="mb-4 text-2xl font-bold text-black">
28
+ {translate("accountDeletion.title")}
29
+ </Heading>
30
+ <Text className="mb-6 text-base text-gray-700">{translate("accountDeletion.body")}</Text>
31
+ <CtaButton href={url}>{translate("accountDeletion.action")}</CtaButton>
32
+ </EmailLayout>
29
33
  );
30
34
  }
@@ -1,31 +1,33 @@
1
- import { Text } from "@react-email/components";
2
- import { BaseEmail } from "../components/BaseEmail";
1
+ import { Heading, Text } from "@react-email/components";
2
+ import { CtaButton } from "@m5kdev/email/components/CtaButton";
3
+ import { EmailLayout } from "@m5kdev/email/components/EmailLayout";
4
+ import type { EmailTranslateFn, OrganizationInviteTemplateProps } from "@m5kdev/email/types";
5
+
6
+ function resolveT(t?: EmailTranslateFn): EmailTranslateFn {
7
+ return t ?? ((key: string) => key);
8
+ }
3
9
 
4
10
  export default function OrganizationInviteEmail({
5
- previewText = "You have been invited to join an organization",
11
+ previewText,
6
12
  inviterName,
7
13
  organizationName,
8
14
  role,
9
15
  url,
10
- }: {
11
- previewText?: string;
12
- inviterName: string;
13
- organizationName: string;
14
- role: string;
15
- url: string;
16
- }) {
16
+ brand,
17
+ t,
18
+ htmlLang,
19
+ }: OrganizationInviteTemplateProps) {
20
+ const translate = resolveT(t);
21
+
17
22
  return (
18
- <BaseEmail
19
- previewText={previewText}
20
- eyebrow="Collaboration"
21
- title={`Join ${organizationName}`}
22
- ctaLabel="Accept invitation"
23
- ctaUrl={url}
24
- body={
25
- <Text style={{ margin: "0" }}>
26
- {inviterName} invited you to join {organizationName} as {role}.
27
- </Text>
28
- }
29
- />
23
+ <EmailLayout previewText={previewText} brand={brand} htmlLang={htmlLang}>
24
+ <Heading className="mb-4 text-2xl font-bold text-black">
25
+ {translate("organizationInvite.title", { organizationName })}
26
+ </Heading>
27
+ <Text className="mb-6 text-base text-gray-700">
28
+ {translate("organizationInvite.body", { inviterName, organizationName, role })}
29
+ </Text>
30
+ <CtaButton href={url}>{translate("organizationInvite.action")}</CtaButton>
31
+ </EmailLayout>
30
32
  );
31
33
  }
@@ -1,25 +1,34 @@
1
- import { Text } from "@react-email/components";
2
- import { BaseEmail } from "../components/BaseEmail";
1
+ import { Heading, Text } from "@react-email/components";
2
+ import { CtaButton } from "@m5kdev/email/components/CtaButton";
3
+ import { EmailLayout } from "@m5kdev/email/components/EmailLayout";
4
+ import type { Brand, EmailTranslateFn } from "@m5kdev/email/types";
5
+
6
+ function resolveT(t?: EmailTranslateFn): EmailTranslateFn {
7
+ return t ?? ((key: string) => key);
8
+ }
3
9
 
4
10
  export default function PasswordResetEmail({
5
- previewText = "Reset your password",
11
+ previewText,
6
12
  url,
13
+ brand,
14
+ t,
15
+ htmlLang,
7
16
  }: {
8
- previewText?: string;
17
+ previewText: string;
9
18
  url: string;
19
+ brand: Brand;
20
+ t?: EmailTranslateFn;
21
+ htmlLang?: string;
10
22
  }) {
23
+ const translate = resolveT(t);
24
+
11
25
  return (
12
- <BaseEmail
13
- previewText={previewText}
14
- eyebrow="Access"
15
- title="Reset your password"
16
- ctaLabel="Choose a new password"
17
- ctaUrl={url}
18
- body={
19
- <Text style={{ margin: "0" }}>
20
- Use the secure link below to choose a new password for your {{APP_NAME}} account.
21
- </Text>
22
- }
23
- />
26
+ <EmailLayout previewText={previewText} brand={brand} htmlLang={htmlLang}>
27
+ <Heading className="mb-4 text-2xl font-bold text-black">
28
+ {translate("passwordReset.title")}
29
+ </Heading>
30
+ <Text className="mb-6 text-base text-gray-700">{translate("passwordReset.body")}</Text>
31
+ <CtaButton href={url}>{translate("passwordReset.action")}</CtaButton>
32
+ </EmailLayout>
24
33
  );
25
34
  }
@@ -1,26 +1,36 @@
1
- import { Text } from "@react-email/components";
2
- import { BaseEmail } from "../components/BaseEmail";
1
+ import { Heading, Text } from "@react-email/components";
2
+ import { CtaButton } from "@m5kdev/email/components/CtaButton";
3
+ import { EmailLayout } from "@m5kdev/email/components/EmailLayout";
4
+ import type { Brand, EmailTranslateFn } from "@m5kdev/email/types";
5
+
6
+ function resolveT(t?: EmailTranslateFn): EmailTranslateFn {
7
+ return t ?? ((key: string) => key);
8
+ }
3
9
 
4
10
  export default function VerificationEmail({
5
- previewText = "Verify your email",
11
+ previewText,
6
12
  url,
13
+ brand,
14
+ t,
15
+ htmlLang,
7
16
  }: {
8
- previewText?: string;
17
+ previewText: string;
9
18
  url: string;
19
+ brand: Brand;
20
+ t?: EmailTranslateFn;
21
+ htmlLang?: string;
10
22
  }) {
23
+ const translate = resolveT(t);
24
+
11
25
  return (
12
- <BaseEmail
13
- previewText={previewText}
14
- eyebrow="Welcome"
15
- title="Verify your email"
16
- ctaLabel="Verify account"
17
- ctaUrl={url}
18
- body={
19
- <Text style={{ margin: "0" }}>
20
- Confirm your email address to finish setting up {{APP_NAME}} and access your editorial
21
- workspace.
22
- </Text>
23
- }
24
- />
26
+ <EmailLayout previewText={previewText} brand={brand} htmlLang={htmlLang}>
27
+ <Heading className="mb-4 text-2xl font-bold text-black">
28
+ {translate("verification.title")}
29
+ </Heading>
30
+ <Text className="mb-6 text-base text-gray-700">
31
+ {translate("verification.body", { appName: "{{APP_NAME}}" })}
32
+ </Text>
33
+ <CtaButton href={url}>{translate("verification.action")}</CtaButton>
34
+ </EmailLayout>
25
35
  );
26
36
  }
@@ -7,26 +7,26 @@ import VerificationEmail from "./emails/verificationEmail";
7
7
  export const templates = {
8
8
  accountDeletion: {
9
9
  id: "account-deletion",
10
- subject: "Delete your account",
11
- previewText: "Confirm your account deletion",
10
+ subject: "accountDeletion.subject",
11
+ previewText: "accountDeletion.previewText",
12
12
  react: AccountDeletionEmail as FunctionComponent<Record<string, unknown>>,
13
13
  },
14
14
  passwordReset: {
15
15
  id: "password-reset",
16
- subject: "Reset your password",
17
- previewText: "Reset your password request",
16
+ subject: "passwordReset.subject",
17
+ previewText: "passwordReset.previewText",
18
18
  react: PasswordResetEmail as FunctionComponent<Record<string, unknown>>,
19
19
  },
20
20
  verification: {
21
21
  id: "verification",
22
- subject: "Verify your email",
23
- previewText: "Verify your email address",
22
+ subject: "verification.subject",
23
+ previewText: "verification.previewText",
24
24
  react: VerificationEmail as FunctionComponent<Record<string, unknown>>,
25
25
  },
26
26
  organizationInvite: {
27
27
  id: "organization-invite",
28
- subject: "Join the organization",
29
- previewText: "You have been invited to join an organization",
28
+ subject: "organizationInvite.subject",
29
+ previewText: "organizationInvite.previewText",
30
30
  react: OrganizationInviteEmail as FunctionComponent<Record<string, unknown>>,
31
31
  },
32
32
  };
@@ -0,0 +1,36 @@
1
+ import type { BackendAppI18nResources } from "@m5kdev/backend/i18n/app-i18n";
2
+
3
+ export const emailResources = {
4
+ en: {
5
+ translation: {
6
+ verification: {
7
+ subject: "Verify your email",
8
+ previewText: "Verify your email address",
9
+ title: "Verify your email",
10
+ body: "Confirm your email address to finish setting up {{APP_NAME}} and access your editorial workspace.",
11
+ action: "Verify account",
12
+ },
13
+ passwordReset: {
14
+ subject: "Reset your password",
15
+ previewText: "Reset your password request",
16
+ title: "Reset your password",
17
+ body: "Use this link to choose a new password for your account.",
18
+ action: "Reset password",
19
+ },
20
+ accountDeletion: {
21
+ subject: "Delete your account",
22
+ previewText: "Confirm your account deletion",
23
+ title: "Confirm account deletion",
24
+ body: "Use this link to confirm deleting your account.",
25
+ action: "Confirm deletion",
26
+ },
27
+ organizationInvite: {
28
+ subject: "Join the organization",
29
+ previewText: "You have been invited to join an organization",
30
+ title: "Join {{organizationName}}",
31
+ body: "{{inviterName}} invited you to join {{organizationName}} as {{role}}.",
32
+ action: "Accept invitation",
33
+ },
34
+ },
35
+ },
36
+ } satisfies BackendAppI18nResources;
@@ -5,7 +5,11 @@ import { EmailModule } from "@m5kdev/backend/modules/email/email.module";
5
5
  import { NotificationModule } from "@m5kdev/backend/modules/notification/notification.module";
6
6
  import { WorkflowModule } from "@m5kdev/backend/modules/workflow/workflow.module";
7
7
  import { templates } from "{{PACKAGE_SCOPE}}/email";
8
+ import { emailResources } from "{{PACKAGE_SCOPE}}/email/resources";
8
9
  import { APP_NAME } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
10
+ import { AUTH_LOCALE_CONFIG } from "{{PACKAGE_SCOPE}}/shared/modules/app/locale.constants";
11
+ import { APP_ROLES_CONFIG } from "{{PACKAGE_SCOPE}}/shared/modules/app/roles.constants";
12
+ import { USER_LOCALE_HEADER } from "@m5kdev/commons/modules/auth/auth.constants";
9
13
  import cors from "cors";
10
14
  import express from "express";
11
15
  import { schema } from "./generated/schema";
@@ -46,6 +50,7 @@ app.use(
46
50
  "Waitlist-Invitation-Code",
47
51
  "Organization-Invitation-Code",
48
52
  "Admin-Create-Verified-User",
53
+ USER_LOCALE_HEADER,
49
54
  ],
50
55
  })
51
56
  );
@@ -61,6 +66,11 @@ export const builtBackendApp = createBackendApp(
61
66
  web: appUrl,
62
67
  api: serverUrl,
63
68
  },
69
+ locales: AUTH_LOCALE_CONFIG,
70
+ roles: APP_ROLES_CONFIG,
71
+ },
72
+ i18n: {
73
+ resources: emailResources,
64
74
  },
65
75
  redis: {
66
76
  url: redisUrl,
@@ -74,7 +84,7 @@ export const builtBackendApp = createBackendApp(
74
84
  outputDirectory: ".emails",
75
85
  },
76
86
  auth: {
77
- factory({ db, services, appConfig }) {
87
+ factory({ db, services, appConfig, i18n }) {
78
88
  return createBetterAuth({
79
89
  orm: db.orm as never,
80
90
  schema: db.schema as never,
@@ -82,6 +92,7 @@ export const builtBackendApp = createBackendApp(
82
92
  email: services.email.email,
83
93
  },
84
94
  app: appConfig,
95
+ i18n,
85
96
  config: {
86
97
  waitlist: enableWaitlist,
87
98
  provisionedAccountEmailDomain: "provisioned.{{APP_SLUG}}.local",
@@ -0,0 +1,7 @@
1
+ export const AUTH_LOCALE_CONFIG = {
2
+ defaultLocale: "en",
3
+ locales: [
4
+ { code: "en", displayName: "English" },
5
+ { code: "en_GB", displayName: "English (UK)" },
6
+ ],
7
+ } as const;
@@ -0,0 +1,17 @@
1
+ import { defineAuthRoles } from "@m5kdev/commons/modules/auth/auth.roles";
2
+
3
+ export const APP_ROLES_CONFIG = defineAuthRoles({
4
+ user: {
5
+ roles: ["user", "admin"],
6
+ },
7
+ organization: {
8
+ roles: ["member", "admin", "owner"],
9
+ managerRoles: ["admin", "owner"],
10
+ assignableRoles: ["member", "admin", "owner"],
11
+ defaultRole: "member",
12
+ },
13
+ team: {
14
+ roles: ["owner"],
15
+ defaultRole: "owner",
16
+ },
17
+ });
@@ -5,6 +5,8 @@ import { DialogProvider } from "@m5kdev/web-ui/components/DialogProvider";
5
5
  import { ThemeProvider } from "@m5kdev/web-ui/components/theme-provider";
6
6
  import { AppLoader } from "@m5kdev/web-ui/modules/app/components/AppLoader";
7
7
  import { APP_NAME } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
8
+ import { AUTH_LOCALE_CONFIG } from "{{PACKAGE_SCOPE}}/shared/modules/app/locale.constants";
9
+ import { APP_ROLES_CONFIG } from "{{PACKAGE_SCOPE}}/shared/modules/app/roles.constants";
8
10
  import { Toaster } from "sonner";
9
11
  import { Router } from "./Router";
10
12
 
@@ -15,6 +17,8 @@ export function Providers() {
15
17
  appName: APP_NAME,
16
18
  appUrl: import.meta.env.VITE_APP_URL,
17
19
  serverUrl: import.meta.env.VITE_SERVER_URL,
20
+ locales: AUTH_LOCALE_CONFIG,
21
+ roles: APP_ROLES_CONFIG,
18
22
  }}
19
23
  >
20
24
  <ThemeProvider defaultTheme="light" storageKey="m5kdev-theme">
@@ -5,6 +5,7 @@ import { AuthOrganizationChildOrganizationsRoute } from "@m5kdev/web-ui/modules/
5
5
  import { AuthOrganizationMembersRoute } from "@m5kdev/web-ui/modules/auth/components/AuthOrganizationMembersRoute";
6
6
  import { AuthOrganizationPreferences } from "@m5kdev/web-ui/modules/auth/components/AuthOrganizationPreferences";
7
7
  import { AuthPublicRouter } from "@m5kdev/web-ui/modules/auth/components/AuthPublicRouter";
8
+ import { syncI18nLocale } from "@m5kdev/frontend/modules/app/utils/locale";
8
9
  import { AuthUserRouter } from "@m5kdev/web-ui/modules/auth/components/AuthUserRouter";
9
10
  import { APP_NAME } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
10
11
  import type { ReactNode } from "react";
@@ -65,6 +66,7 @@ export function Router() {
65
66
  {AuthPublicRouter({
66
67
  header: <AuthHeader />,
67
68
  waitlist: isWaitlist,
69
+ onLocaleChange: syncI18nLocale,
68
70
  })}
69
71
 
70
72
  <Route
@@ -78,11 +80,11 @@ export function Router() {
78
80
  <Route path="posts" element={<PostsRoute />} />
79
81
  <Route
80
82
  path="organization/members"
81
- element={<AuthOrganizationMembersRoute managerRoles={["admin", "owner"]} />}
83
+ element={<AuthOrganizationMembersRoute />}
82
84
  />
83
85
  <Route
84
86
  path="organization/manage"
85
- element={<AuthOrganizationChildOrganizationsRoute managerRoles={["admin", "owner"]} />}
87
+ element={<AuthOrganizationChildOrganizationsRoute />}
86
88
  />
87
89
  <Route
88
90
  path="organization/preferences"
@@ -90,13 +92,13 @@ export function Router() {
90
92
  <AuthOrganizationPreferences
91
93
  schema={preferenceSchema}
92
94
  controls={preferenceControls}
93
- managerRoles={["admin", "owner"]}
94
95
  />
95
96
  }
96
97
  />
97
98
  {AuthUserRouter({
98
99
  schema: preferenceSchema,
99
100
  controls: preferenceControls,
101
+ onLocaleChange: syncI18nLocale,
100
102
  })}
101
103
  {AuthAdminRouter({
102
104
  enableWaitlist: true,
@@ -0,0 +1,7 @@
1
+ {
2
+ "user.role.user": "User",
3
+ "user.role.admin": "Admin",
4
+ "organization.role.member": "Member",
5
+ "organization.role.admin": "Admin",
6
+ "organization.role.owner": "Owner"
7
+ }
@@ -1,117 +0,0 @@
1
- import {
2
- Body,
3
- Button,
4
- Container,
5
- Head,
6
- Heading,
7
- Hr,
8
- Html,
9
- Preview,
10
- Section,
11
- Text,
12
- } from "@react-email/components";
13
- import type { ReactNode } from "react";
14
-
15
- interface BaseEmailProps {
16
- previewText: string;
17
- title: string;
18
- eyebrow: string;
19
- body: ReactNode;
20
- ctaLabel?: string;
21
- ctaUrl?: string;
22
- }
23
-
24
- export function BaseEmail({ previewText, title, eyebrow, body, ctaLabel, ctaUrl }: BaseEmailProps) {
25
- return (
26
- <Html>
27
- <Head />
28
- <Preview>{previewText}</Preview>
29
- <Body
30
- style={{
31
- backgroundColor: "#f3efe6",
32
- fontFamily: "'Manrope', 'Helvetica Neue', Arial, sans-serif",
33
- margin: "0",
34
- padding: "32px 0",
35
- }}
36
- >
37
- <Container
38
- style={{
39
- backgroundColor: "#fffaf1",
40
- border: "1px solid #d6c7ab",
41
- borderRadius: "24px",
42
- margin: "0 auto",
43
- maxWidth: "620px",
44
- padding: "40px",
45
- }}
46
- >
47
- <Text
48
- style={{
49
- color: "#8b5e34",
50
- fontSize: "12px",
51
- fontWeight: "700",
52
- letterSpacing: "0.18em",
53
- margin: "0 0 16px",
54
- textTransform: "uppercase",
55
- }}
56
- >
57
- {eyebrow}
58
- </Text>
59
- <Heading
60
- style={{
61
- color: "#171314",
62
- fontFamily: "'Cormorant Garamond', Georgia, serif",
63
- fontSize: "34px",
64
- lineHeight: "1.1",
65
- margin: "0 0 20px",
66
- }}
67
- >
68
- {title}
69
- </Heading>
70
- <Section
71
- style={{
72
- color: "#463630",
73
- fontSize: "16px",
74
- lineHeight: "1.7",
75
- }}
76
- >
77
- {body}
78
- </Section>
79
- {ctaLabel && ctaUrl ? (
80
- <Section style={{ marginTop: "28px" }}>
81
- <Button
82
- href={ctaUrl}
83
- style={{
84
- backgroundColor: "#1f4f46",
85
- borderRadius: "999px",
86
- color: "#fdf9f1",
87
- fontSize: "15px",
88
- fontWeight: "700",
89
- padding: "14px 24px",
90
- textDecoration: "none",
91
- }}
92
- >
93
- {ctaLabel}
94
- </Button>
95
- </Section>
96
- ) : null}
97
- <Hr
98
- style={{
99
- borderColor: "#e5dccd",
100
- margin: "28px 0",
101
- }}
102
- />
103
- <Text
104
- style={{
105
- color: "#7f6b5d",
106
- fontSize: "13px",
107
- lineHeight: "1.6",
108
- margin: "0",
109
- }}
110
- >
111
- Sent by {{APP_NAME}}.
112
- </Text>
113
- </Container>
114
- </Body>
115
- </Html>
116
- );
117
- }