create-m5kdev 0.25.2 → 0.26.1
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/dist/src/create.cjs +18 -1
- package/dist/src/create.cjs.map +1 -1
- package/dist/src/fs.cjs +65 -5
- package/dist/src/fs.cjs.map +1 -1
- package/dist/src/fs.d.cts +13 -2
- package/dist/src/index.cjs +0 -0
- package/dist/src/paths.cjs +2 -2
- package/dist/src/prompts.cjs +22 -0
- package/dist/src/prompts.cjs.map +1 -1
- package/dist/src/runCli.cjs +13 -2
- package/dist/src/runCli.cjs.map +1 -1
- package/dist/src/types.d.cts +9 -1
- package/package.json +8 -6
- package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +46 -3
- package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +15 -1
- package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -0
- package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +98 -193
- package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -173
- package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +133 -240
- package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -113
- package/templates/minimal-app/README.md.tpl +3 -3
- package/templates/minimal-app/apps/.gitignore.tpl +13 -0
- package/templates/minimal-app/apps/e2e/package.json.tpl +23 -0
- package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +39 -0
- package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -0
- package/templates/minimal-app/apps/e2e/servers.ts.tpl +100 -0
- package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -0
- package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -0
- package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -0
- package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -0
- package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -0
- package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -0
- package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -0
- package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -0
- package/templates/minimal-app/apps/email/package.json.tpl +34 -34
- package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +35 -34
- package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +34 -33
- package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +35 -34
- package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +37 -36
- package/templates/minimal-app/apps/email/src/emails/waitlistEmails.tsx.tpl +87 -0
- package/templates/minimal-app/apps/email/src/index.ts.tpl +72 -32
- package/templates/minimal-app/apps/email/src/resources.ts.tpl +65 -36
- package/templates/minimal-app/apps/email/tsconfig.json.tpl +16 -11
- package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -0
- package/templates/minimal-app/apps/expo/.prettierignore +13 -0
- package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -0
- package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -0
- package/templates/minimal-app/apps/expo/README.md.tpl +44 -0
- package/templates/minimal-app/apps/expo/app.json.tpl +36 -0
- package/templates/minimal-app/apps/expo/assets/images/adaptive-icon.png +0 -0
- package/templates/minimal-app/apps/expo/assets/images/favicon.png +0 -0
- package/templates/minimal-app/apps/expo/assets/images/icon.png +0 -0
- package/templates/minimal-app/apps/expo/assets/images/splash-icon.png +0 -0
- package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -0
- package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
- package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -0
- package/templates/minimal-app/apps/expo/package.json.tpl +59 -0
- package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +31 -0
- package/templates/minimal-app/apps/expo/src/app/claim-account.tsx.tpl +151 -0
- package/templates/minimal-app/apps/expo/src/app/forgot-password.tsx.tpl +53 -0
- package/templates/minimal-app/apps/expo/src/app/index.tsx.tpl +8 -0
- package/templates/minimal-app/apps/expo/src/app/login.tsx.tpl +71 -0
- package/templates/minimal-app/apps/expo/src/app/logout.tsx.tpl +19 -0
- package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +85 -0
- package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +40 -0
- package/templates/minimal-app/apps/expo/src/app/reset-password.tsx.tpl +75 -0
- package/templates/minimal-app/apps/expo/src/app/signup.tsx.tpl +193 -0
- package/templates/minimal-app/apps/expo/src/components/AuthScaffold.tsx.tpl +34 -0
- package/templates/minimal-app/apps/expo/src/components/FormTextField.tsx.tpl +37 -0
- package/templates/minimal-app/apps/expo/src/components/LoadingScreen.tsx.tpl +10 -0
- package/templates/minimal-app/apps/expo/src/components/StatusText.tsx.tpl +22 -0
- package/templates/minimal-app/apps/expo/src/config.ts.tpl +9 -0
- package/templates/minimal-app/apps/expo/src/global.css.tpl +11 -0
- package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +20 -0
- package/templates/minimal-app/apps/expo/tsconfig.json.tpl +11 -0
- package/templates/minimal-app/apps/expo/uniwind-env.d.ts.tpl +1 -0
- package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
- package/templates/minimal-app/apps/server/AGENTS.md.tpl +39 -39
- package/templates/minimal-app/apps/server/drizzle/0000_wealthy_absorbing_man.sql +268 -0
- package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +26 -0
- package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +1890 -0
- package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +13 -0
- package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +33 -0
- package/templates/minimal-app/apps/server/drizzle/seed.e2e.ts.tpl +244 -0
- package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +181 -180
- package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +14 -21
- package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +33 -35
- package/templates/minimal-app/apps/server/package.json.tpl +56 -52
- package/templates/minimal-app/apps/server/src/app.ts.tpl +142 -126
- package/templates/minimal-app/apps/server/src/index.ts.tpl +58 -58
- package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +12 -12
- package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +25 -25
- package/templates/minimal-app/apps/server/src/modules/posts/posts.dto.ts.tpl +23 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +30 -40
- package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +54 -54
- package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -112
- package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +104 -170
- package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -48
- package/templates/minimal-app/apps/server/src/modules/posts/posts.utils.ts.tpl +19 -0
- package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -0
- package/templates/minimal-app/apps/server/src/schema.ts.tpl +20 -0
- package/templates/minimal-app/apps/server/src/types.ts.tpl +1 -1
- package/templates/minimal-app/apps/server/tsconfig.json.tpl +20 -14
- package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -9
- package/templates/minimal-app/apps/shared/package.json.tpl +24 -24
- package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -2
- package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
- package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
- package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -76
- package/templates/minimal-app/apps/shared/tsconfig.json.tpl +16 -12
- package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
- package/templates/minimal-app/apps/webapp/package.json.tpl +53 -52
- package/templates/minimal-app/apps/webapp/public/{push-sw.js → push-sw.js.tpl} +34 -34
- package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +44 -13
- package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +18 -12
- package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +33 -40
- package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -75
- package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
- package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +278 -307
- package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostCard.tsx.tpl +96 -138
- package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl +118 -124
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostActions.ts.tpl +108 -116
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsList.ts.tpl +15 -9
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsRoute.ts.tpl +13 -13
- package/templates/minimal-app/apps/webapp/src/modules/posts/posts.utils.ts.tpl +16 -16
- package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
- package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -6
- package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
- package/templates/minimal-app/apps/webapp/translations/{app.json.tpl → en/app.json.tpl} +7 -7
- package/templates/minimal-app/apps/webapp/translations/en/{blog-app.json.tpl → {{APP_SLUG}}.json.tpl} +135 -135
- package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
- package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
- package/templates/minimal-app/template.manifest.json +21 -0
- package/templates/minimal-app/apps/server/drizzle/generate-schema.ts.tpl +0 -31
- package/templates/minimal-app/apps/server/src/db.ts.tpl +0 -7
- package/templates/minimal-app/apps/server/src/generated/schema.ts.tpl +0 -13
- package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +0 -8
- package/templates/minimal-app/apps/shared/src/modules/app/locale.constants.ts.tpl +0 -7
- package/templates/minimal-app/apps/shared/src/modules/app/roles.constants.ts.tpl +0 -17
- package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +0 -36
- package/templates/minimal-app/apps/webapp/src/modules/notification/PushNotificationsPanel.tsx.tpl +0 -89
- package/templates/minimal-app/apps/webapp/src/navigation/navigation.config.tsx.tpl +0 -85
- /package/templates/minimal-app/apps/webapp/public/{logo.svg → logo.svg.tpl} +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
|
|
2
|
+
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
3
|
+
import { Link, useRouter } from "expo-router";
|
|
4
|
+
import { Button } from "heroui-native";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { Text, View } from "react-native";
|
|
7
|
+
import { AuthScaffold } from "../components/AuthScaffold";
|
|
8
|
+
import { FormTextField } from "../components/FormTextField";
|
|
9
|
+
import { StatusText } from "../components/StatusText";
|
|
10
|
+
|
|
11
|
+
export default function LoginScreen() {
|
|
12
|
+
const authClient = useAuthClient();
|
|
13
|
+
const { registerSession } = useSession();
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
const [email, setEmail] = useState("");
|
|
16
|
+
const [password, setPassword] = useState("");
|
|
17
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
18
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
19
|
+
|
|
20
|
+
const submit = async () => {
|
|
21
|
+
setIsBusy(true);
|
|
22
|
+
setStatus(null);
|
|
23
|
+
try {
|
|
24
|
+
const result = await authClient.signIn.email({ email, password });
|
|
25
|
+
if (result.error || !result.data?.user) {
|
|
26
|
+
setStatus(result.error?.message ?? "Authentication failed");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
registerSession(() => router.replace("/posts"));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
setStatus(error instanceof Error ? error.message : "Authentication failed");
|
|
32
|
+
} finally {
|
|
33
|
+
setIsBusy(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<AuthScaffold title="Login" description="Sign in to the protected Expo test app.">
|
|
39
|
+
<View className="gap-4">
|
|
40
|
+
<FormTextField
|
|
41
|
+
label="Email"
|
|
42
|
+
testID="login-email"
|
|
43
|
+
value={email}
|
|
44
|
+
onChangeText={setEmail}
|
|
45
|
+
keyboardType="email-address"
|
|
46
|
+
/>
|
|
47
|
+
<FormTextField
|
|
48
|
+
label="Password"
|
|
49
|
+
testID="login-password"
|
|
50
|
+
value={password}
|
|
51
|
+
onChangeText={setPassword}
|
|
52
|
+
secureTextEntry
|
|
53
|
+
/>
|
|
54
|
+
<StatusText testID="auth-error" tone="error">
|
|
55
|
+
{status}
|
|
56
|
+
</StatusText>
|
|
57
|
+
<Button testID="login-submit" onPress={submit} isDisabled={isBusy}>
|
|
58
|
+
Login
|
|
59
|
+
</Button>
|
|
60
|
+
<View className="flex-row justify-between">
|
|
61
|
+
<Link href="/signup" asChild>
|
|
62
|
+
<Text className="text-primary underline">Sign up</Text>
|
|
63
|
+
</Link>
|
|
64
|
+
<Link href="/forgot-password" asChild>
|
|
65
|
+
<Text className="text-primary underline">Forgot password</Text>
|
|
66
|
+
</Link>
|
|
67
|
+
</View>
|
|
68
|
+
</View>
|
|
69
|
+
</AuthScaffold>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
|
|
2
|
+
import { Redirect } from "expo-router";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { LoadingScreen } from "../components/LoadingScreen";
|
|
5
|
+
|
|
6
|
+
export default function LogoutScreen() {
|
|
7
|
+
const authClient = useAuthClient();
|
|
8
|
+
const [done, setDone] = useState(false);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
authClient.signOut().finally(() => setDone(true));
|
|
12
|
+
}, [authClient]);
|
|
13
|
+
|
|
14
|
+
if (!done) {
|
|
15
|
+
return <LoadingScreen />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return <Redirect href="/login" />;
|
|
19
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
|
|
2
|
+
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
3
|
+
import { type Href, Redirect, useLocalSearchParams, useRouter } from "expo-router";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { LoadingScreen } from "../../components/LoadingScreen";
|
|
6
|
+
import { trpcClient } from "../../lib/trpc";
|
|
7
|
+
|
|
8
|
+
function singleParam(value: string | string[] | undefined) {
|
|
9
|
+
return Array.isArray(value) ? value[0] : value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function AcceptInvitationScreen() {
|
|
13
|
+
const authClient = useAuthClient();
|
|
14
|
+
const { data: session, registerSession } = useSession();
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const params = useLocalSearchParams<{ id?: string | string[] }>();
|
|
17
|
+
const invitationId = singleParam(params.id);
|
|
18
|
+
const [errorPath, setErrorPath] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const signupPath = useMemo(() => {
|
|
21
|
+
if (!invitationId) return null;
|
|
22
|
+
return trpcClient.auth.readInvitation
|
|
23
|
+
.query({ id: invitationId })
|
|
24
|
+
.then((invitation) => {
|
|
25
|
+
const search = new URLSearchParams({
|
|
26
|
+
invitation: invitationId,
|
|
27
|
+
email: invitation.email,
|
|
28
|
+
});
|
|
29
|
+
return `/signup?${search.toString()}`;
|
|
30
|
+
})
|
|
31
|
+
.catch(() => "/signup");
|
|
32
|
+
}, [invitationId]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
|
|
37
|
+
if (!invitationId) {
|
|
38
|
+
setErrorPath("/signup");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!session) {
|
|
43
|
+
signupPath?.then((path) => {
|
|
44
|
+
if (!cancelled) router.replace(path as Href);
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
authClient.organization
|
|
50
|
+
.acceptInvitation({ invitationId })
|
|
51
|
+
.then(async ({ data, error }) => {
|
|
52
|
+
if (error) {
|
|
53
|
+
throw new Error(error.message ?? "Unable to accept invitation");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const organizationId =
|
|
57
|
+
(data as { invitation?: { organizationId?: string } | null } | null)?.invitation
|
|
58
|
+
?.organizationId ?? null;
|
|
59
|
+
|
|
60
|
+
if (organizationId) {
|
|
61
|
+
const activeResult = await authClient.organization.setActive({ organizationId });
|
|
62
|
+
if (activeResult.error) {
|
|
63
|
+
throw new Error(activeResult.error.message ?? "Unable to activate organization");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!cancelled) {
|
|
68
|
+
registerSession(() => router.replace("/posts"));
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
.catch(() => {
|
|
72
|
+
if (!cancelled) setErrorPath("/signup");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
}, [authClient, invitationId, registerSession, router, session, signupPath]);
|
|
79
|
+
|
|
80
|
+
if (errorPath) {
|
|
81
|
+
return <Redirect href={errorPath as Href} />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return <LoadingScreen />;
|
|
85
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
2
|
+
import { Link, Redirect } from "expo-router";
|
|
3
|
+
import { Button, Card, Typography } from "heroui-native";
|
|
4
|
+
import { ScrollView, Text, View } from "react-native";
|
|
5
|
+
|
|
6
|
+
export default function PostsScreen() {
|
|
7
|
+
const { data: session } = useSession();
|
|
8
|
+
|
|
9
|
+
if (!session) {
|
|
10
|
+
return <Redirect href="/login" />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<ScrollView className="flex-1 bg-background" contentContainerClassName="px-5 py-8">
|
|
15
|
+
<View className="gap-5">
|
|
16
|
+
<View className="gap-2">
|
|
17
|
+
<Text className="text-3xl font-semibold text-foreground">Editorial posts</Text>
|
|
18
|
+
<Typography.Paragraph className="text-default-600">
|
|
19
|
+
A small blog that protects auth changes across web and Expo.
|
|
20
|
+
</Typography.Paragraph>
|
|
21
|
+
<Text testID="session-email" className="text-sm text-default-600">
|
|
22
|
+
{session.user.email}
|
|
23
|
+
</Text>
|
|
24
|
+
</View>
|
|
25
|
+
<Card className="gap-2 p-5">
|
|
26
|
+
<Text className="text-lg font-semibold text-foreground">Native fixture post</Text>
|
|
27
|
+
<Typography.Paragraph>
|
|
28
|
+
This protected screen is rendered by Expo Router and authenticated through
|
|
29
|
+
@m5kdev/frontend.
|
|
30
|
+
</Typography.Paragraph>
|
|
31
|
+
</Card>
|
|
32
|
+
<Link href="/logout" asChild>
|
|
33
|
+
<Button testID="logout-submit" variant="outline">
|
|
34
|
+
Logout
|
|
35
|
+
</Button>
|
|
36
|
+
</Link>
|
|
37
|
+
</View>
|
|
38
|
+
</ScrollView>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
|
|
2
|
+
import { useLocalSearchParams } from "expo-router";
|
|
3
|
+
import { Button } from "heroui-native";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { View } from "react-native";
|
|
6
|
+
import { AuthScaffold } from "../components/AuthScaffold";
|
|
7
|
+
import { FormTextField } from "../components/FormTextField";
|
|
8
|
+
import { StatusText } from "../components/StatusText";
|
|
9
|
+
|
|
10
|
+
function singleParam(value: string | string[] | undefined) {
|
|
11
|
+
return Array.isArray(value) ? value[0] : value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function ResetPasswordScreen() {
|
|
15
|
+
const authClient = useAuthClient();
|
|
16
|
+
const params = useLocalSearchParams<{ token?: string | string[] }>();
|
|
17
|
+
const token = singleParam(params.token);
|
|
18
|
+
const [newPassword, setNewPassword] = useState("");
|
|
19
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
20
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
21
|
+
const [tone, setTone] = useState<"error" | "success">("success");
|
|
22
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
23
|
+
|
|
24
|
+
const submit = async () => {
|
|
25
|
+
setStatus(null);
|
|
26
|
+
if (!token) {
|
|
27
|
+
setTone("error");
|
|
28
|
+
setStatus("Reset token is required");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (newPassword !== confirmPassword) {
|
|
32
|
+
setTone("error");
|
|
33
|
+
setStatus("Passwords do not match");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
setIsBusy(true);
|
|
37
|
+
try {
|
|
38
|
+
await authClient.resetPassword({ newPassword, token });
|
|
39
|
+
setTone("success");
|
|
40
|
+
setStatus("Password reset successfully");
|
|
41
|
+
} catch (error) {
|
|
42
|
+
setTone("error");
|
|
43
|
+
setStatus(error instanceof Error ? error.message : "Password reset failed");
|
|
44
|
+
} finally {
|
|
45
|
+
setIsBusy(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<AuthScaffold title="Set a new password">
|
|
51
|
+
<View className="gap-4">
|
|
52
|
+
<FormTextField
|
|
53
|
+
label="New password"
|
|
54
|
+
testID="reset-password-new"
|
|
55
|
+
value={newPassword}
|
|
56
|
+
onChangeText={setNewPassword}
|
|
57
|
+
secureTextEntry
|
|
58
|
+
/>
|
|
59
|
+
<FormTextField
|
|
60
|
+
label="Confirm password"
|
|
61
|
+
testID="reset-password-confirm"
|
|
62
|
+
value={confirmPassword}
|
|
63
|
+
onChangeText={setConfirmPassword}
|
|
64
|
+
secureTextEntry
|
|
65
|
+
/>
|
|
66
|
+
<StatusText testID="auth-status" tone={tone}>
|
|
67
|
+
{status}
|
|
68
|
+
</StatusText>
|
|
69
|
+
<Button testID="reset-password-submit" onPress={submit} isDisabled={isBusy}>
|
|
70
|
+
Reset password
|
|
71
|
+
</Button>
|
|
72
|
+
</View>
|
|
73
|
+
</AuthScaffold>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
|
|
2
|
+
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
3
|
+
import { USER_LOCALE_HEADER } from "@m5kdev/commons/modules/auth/auth.constants";
|
|
4
|
+
import { resolveAppLocale } from "@m5kdev/commons/modules/auth/auth.locale";
|
|
5
|
+
import { APP_LOCALE_CONFIG } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
|
|
6
|
+
import { Link, useLocalSearchParams, useRouter } from "expo-router";
|
|
7
|
+
import { Button } from "heroui-native";
|
|
8
|
+
import { useEffect, useMemo, useState } from "react";
|
|
9
|
+
import { Text, View } from "react-native";
|
|
10
|
+
import { AuthScaffold } from "../components/AuthScaffold";
|
|
11
|
+
import { FormTextField } from "../components/FormTextField";
|
|
12
|
+
import { StatusText } from "../components/StatusText";
|
|
13
|
+
import { isWaitlistProfile } from "../config";
|
|
14
|
+
import { trpcClient } from "../lib/trpc";
|
|
15
|
+
|
|
16
|
+
function singleParam(value: string | string[] | undefined) {
|
|
17
|
+
return Array.isArray(value) ? value[0] : value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function SignupScreen() {
|
|
21
|
+
const authClient = useAuthClient();
|
|
22
|
+
const { registerSession } = useSession();
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
const params = useLocalSearchParams<{
|
|
25
|
+
code?: string | string[];
|
|
26
|
+
email?: string | string[];
|
|
27
|
+
invitation?: string | string[];
|
|
28
|
+
}>();
|
|
29
|
+
const code = singleParam(params.code);
|
|
30
|
+
const invitation = singleParam(params.invitation);
|
|
31
|
+
const lockedEmail = singleParam(params.email);
|
|
32
|
+
const isPublicWaitlist = isWaitlistProfile && !code && !invitation;
|
|
33
|
+
const [email, setEmail] = useState(lockedEmail ?? "");
|
|
34
|
+
const [password, setPassword] = useState("");
|
|
35
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
36
|
+
const [statusTone, setStatusTone] = useState<"error" | "success">("success");
|
|
37
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
38
|
+
const [waitlistCodeStatus, setWaitlistCodeStatus] = useState<string | null>(null);
|
|
39
|
+
const [isValidatingWaitlistCode, setIsValidatingWaitlistCode] = useState(false);
|
|
40
|
+
const [waitlistCodeError, setWaitlistCodeError] = useState(false);
|
|
41
|
+
|
|
42
|
+
const waitlistStatusText = useMemo(() => {
|
|
43
|
+
if (!code) return null;
|
|
44
|
+
if (isValidatingWaitlistCode) return "Validating the invitation code...";
|
|
45
|
+
if (waitlistCodeError) return "An error occurred while validating the invitation code.";
|
|
46
|
+
if (waitlistCodeStatus === "VALID") return "Invitation code is valid. You can proceed.";
|
|
47
|
+
if (waitlistCodeStatus === "EXPIRED") return "Invitation code has expired.";
|
|
48
|
+
if (waitlistCodeStatus === "NOT_FOUND") return "Invitation code not found.";
|
|
49
|
+
if (waitlistCodeStatus) return "Invalid invitation code.";
|
|
50
|
+
return null;
|
|
51
|
+
}, [code, isValidatingWaitlistCode, waitlistCodeError, waitlistCodeStatus]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (lockedEmail) setEmail(lockedEmail);
|
|
55
|
+
}, [lockedEmail]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
if (!code) {
|
|
60
|
+
setWaitlistCodeStatus(null);
|
|
61
|
+
setWaitlistCodeError(false);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setIsValidatingWaitlistCode(true);
|
|
66
|
+
setWaitlistCodeError(false);
|
|
67
|
+
trpcClient.auth.validateWaitlistCode
|
|
68
|
+
.query({ code })
|
|
69
|
+
.then((result) => {
|
|
70
|
+
if (!cancelled) setWaitlistCodeStatus(result.status);
|
|
71
|
+
})
|
|
72
|
+
.catch(() => {
|
|
73
|
+
if (!cancelled) setWaitlistCodeError(true);
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
if (!cancelled) setIsValidatingWaitlistCode(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
};
|
|
82
|
+
}, [code]);
|
|
83
|
+
|
|
84
|
+
const submitWaitlist = async () => {
|
|
85
|
+
setIsBusy(true);
|
|
86
|
+
setStatus(null);
|
|
87
|
+
try {
|
|
88
|
+
await trpcClient.auth.joinWaitlist.mutate({ email });
|
|
89
|
+
setStatusTone("success");
|
|
90
|
+
setStatus("Waitlist confirmation email sent");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
setStatusTone("error");
|
|
93
|
+
setStatus(error instanceof Error ? error.message : "Failed to join waitlist");
|
|
94
|
+
} finally {
|
|
95
|
+
setIsBusy(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const submitSignup = async () => {
|
|
100
|
+
setIsBusy(true);
|
|
101
|
+
setStatus(null);
|
|
102
|
+
try {
|
|
103
|
+
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
104
|
+
const userLocale = resolveAppLocale(deviceLocale, APP_LOCALE_CONFIG);
|
|
105
|
+
const result = await authClient.signUp.email(
|
|
106
|
+
{
|
|
107
|
+
name: email,
|
|
108
|
+
email,
|
|
109
|
+
password,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
headers: {
|
|
113
|
+
"Waitlist-Invitation-Code": code ?? "",
|
|
114
|
+
"Organization-Invitation-Code": invitation ?? "",
|
|
115
|
+
[USER_LOCALE_HEADER]: userLocale,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
if (result.error) {
|
|
120
|
+
setStatusTone("error");
|
|
121
|
+
setStatus(result.error.message ?? "Signup failed");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (isWaitlistProfile || invitation) {
|
|
125
|
+
const loginResult = await authClient.signIn.email({ email, password });
|
|
126
|
+
if (loginResult.error || !loginResult.data?.user) {
|
|
127
|
+
setStatusTone("error");
|
|
128
|
+
setStatus(loginResult.error?.message ?? "Authentication failed");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
registerSession(() => router.replace("/posts"));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setStatusTone("success");
|
|
135
|
+
setStatus("Verification email sent");
|
|
136
|
+
} catch (error) {
|
|
137
|
+
setStatusTone("error");
|
|
138
|
+
setStatus(error instanceof Error ? error.message : "Signup failed");
|
|
139
|
+
} finally {
|
|
140
|
+
setIsBusy(false);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<AuthScaffold
|
|
146
|
+
title={isPublicWaitlist ? "Join the waitlist" : "Sign up"}
|
|
147
|
+
description={
|
|
148
|
+
isPublicWaitlist
|
|
149
|
+
? "Request access to the waitlist test profile."
|
|
150
|
+
: "Create a test account for the Expo app."
|
|
151
|
+
}
|
|
152
|
+
>
|
|
153
|
+
<View className="gap-4">
|
|
154
|
+
<FormTextField
|
|
155
|
+
label="Email"
|
|
156
|
+
testID={isPublicWaitlist ? "waitlist-email" : "signup-email"}
|
|
157
|
+
value={email}
|
|
158
|
+
onChangeText={setEmail}
|
|
159
|
+
keyboardType="email-address"
|
|
160
|
+
editable={!lockedEmail}
|
|
161
|
+
/>
|
|
162
|
+
{isPublicWaitlist ? null : (
|
|
163
|
+
<FormTextField
|
|
164
|
+
label="Password"
|
|
165
|
+
testID="signup-password"
|
|
166
|
+
value={password}
|
|
167
|
+
onChangeText={setPassword}
|
|
168
|
+
secureTextEntry
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
<StatusText
|
|
172
|
+
testID="waitlist-code-status"
|
|
173
|
+
tone={waitlistCodeStatus === "VALID" ? "success" : "error"}
|
|
174
|
+
>
|
|
175
|
+
{waitlistStatusText}
|
|
176
|
+
</StatusText>
|
|
177
|
+
<StatusText testID="auth-status" tone={statusTone}>
|
|
178
|
+
{status}
|
|
179
|
+
</StatusText>
|
|
180
|
+
<Button
|
|
181
|
+
testID={isPublicWaitlist ? "waitlist-submit" : "signup-submit"}
|
|
182
|
+
onPress={isPublicWaitlist ? submitWaitlist : submitSignup}
|
|
183
|
+
isDisabled={isBusy}
|
|
184
|
+
>
|
|
185
|
+
{isPublicWaitlist ? "Join the waitlist" : "Sign up"}
|
|
186
|
+
</Button>
|
|
187
|
+
<Link href="/login" asChild>
|
|
188
|
+
<Text className="text-center text-primary underline">Already have an account?</Text>
|
|
189
|
+
</Link>
|
|
190
|
+
</View>
|
|
191
|
+
</AuthScaffold>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Card, Typography } from "heroui-native";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { ScrollView, Text, View } from "react-native";
|
|
4
|
+
import { appTitle } from "../config";
|
|
5
|
+
|
|
6
|
+
export function AuthScaffold({
|
|
7
|
+
children,
|
|
8
|
+
description,
|
|
9
|
+
title,
|
|
10
|
+
}: {
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
description?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<ScrollView className="flex-1 bg-background" contentContainerClassName="flex-grow px-5 py-8">
|
|
17
|
+
<View className="flex-1 justify-center gap-5">
|
|
18
|
+
<View className="gap-2">
|
|
19
|
+
<Text className="text-center text-xs font-semibold uppercase tracking-widest text-default-500">
|
|
20
|
+
Expo auth fixture
|
|
21
|
+
</Text>
|
|
22
|
+
<Text className="text-center text-3xl font-semibold text-foreground">{appTitle}</Text>
|
|
23
|
+
<Typography.Paragraph className="text-center text-default-600">
|
|
24
|
+
{description ?? "Native auth screens backed by @m5kdev/frontend."}
|
|
25
|
+
</Typography.Paragraph>
|
|
26
|
+
</View>
|
|
27
|
+
<Card className="gap-5 p-5">
|
|
28
|
+
<Text className="text-xl font-semibold text-foreground">{title}</Text>
|
|
29
|
+
{children}
|
|
30
|
+
</Card>
|
|
31
|
+
</View>
|
|
32
|
+
</ScrollView>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Input, Label, TextField } from "heroui-native";
|
|
2
|
+
import type { TextInputProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
export function FormTextField({
|
|
5
|
+
label,
|
|
6
|
+
testID,
|
|
7
|
+
value,
|
|
8
|
+
onChangeText,
|
|
9
|
+
secureTextEntry,
|
|
10
|
+
keyboardType,
|
|
11
|
+
autoCapitalize = "none",
|
|
12
|
+
editable = true,
|
|
13
|
+
}: {
|
|
14
|
+
label: string;
|
|
15
|
+
testID: string;
|
|
16
|
+
value: string;
|
|
17
|
+
onChangeText: (value: string) => void;
|
|
18
|
+
secureTextEntry?: boolean;
|
|
19
|
+
keyboardType?: TextInputProps["keyboardType"];
|
|
20
|
+
autoCapitalize?: TextInputProps["autoCapitalize"];
|
|
21
|
+
editable?: boolean;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<TextField isRequired isDisabled={!editable}>
|
|
25
|
+
<Label>{label}</Label>
|
|
26
|
+
<Input
|
|
27
|
+
testID={testID}
|
|
28
|
+
value={value}
|
|
29
|
+
onChangeText={onChangeText}
|
|
30
|
+
secureTextEntry={secureTextEntry}
|
|
31
|
+
keyboardType={keyboardType}
|
|
32
|
+
autoCapitalize={autoCapitalize}
|
|
33
|
+
editable={editable}
|
|
34
|
+
/>
|
|
35
|
+
</TextField>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Typography } from "heroui-native";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
|
|
4
|
+
export function LoadingScreen() {
|
|
5
|
+
return (
|
|
6
|
+
<View className="flex-1 items-center justify-center bg-background px-6">
|
|
7
|
+
<Typography.Paragraph testID="loading-state">Loading auth session...</Typography.Paragraph>
|
|
8
|
+
</View>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Text } from "react-native";
|
|
2
|
+
|
|
3
|
+
export function StatusText({
|
|
4
|
+
children,
|
|
5
|
+
tone = "default",
|
|
6
|
+
testID,
|
|
7
|
+
}: {
|
|
8
|
+
children: string | null;
|
|
9
|
+
tone?: "default" | "error" | "success";
|
|
10
|
+
testID?: string;
|
|
11
|
+
}) {
|
|
12
|
+
if (!children) return null;
|
|
13
|
+
|
|
14
|
+
const toneClass =
|
|
15
|
+
tone === "error" ? "text-danger" : tone === "success" ? "text-success" : "text-default-600";
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Text testID={testID} className={`text-sm ${toneClass}`}>
|
|
19
|
+
{children}
|
|
20
|
+
</Text>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { APP_NAME } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
|
|
2
|
+
|
|
3
|
+
export const serverUrl = process.env.EXPO_PUBLIC_SERVER_URL ?? "http://127.0.0.1:8080";
|
|
4
|
+
|
|
5
|
+
export const appUrl = process.env.EXPO_PUBLIC_APP_URL ?? "http://127.0.0.1:8081";
|
|
6
|
+
|
|
7
|
+
export const isWaitlistProfile = process.env.EXPO_PUBLIC_ENABLE_WAITLIST === "true";
|
|
8
|
+
|
|
9
|
+
export const appTitle = APP_NAME;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "uniwind";
|
|
3
|
+
|
|
4
|
+
@import "heroui-native/styles";
|
|
5
|
+
|
|
6
|
+
/* End-user path: heroui-native lives in the project's node_modules, one
|
|
7
|
+
level up from this src/global.css file. */
|
|
8
|
+
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind v4 source directive. */
|
|
9
|
+
@source '../node_modules/heroui-native/lib';
|
|
10
|
+
/* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind v4 source directive for workspace package classes. */
|
|
11
|
+
@source '../../../../packages/frontend/dist/src/**/*.{js,ts,jsx,tsx}';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { BackendTRPCRouter } from "@m5kdev/backend/types";
|
|
2
|
+
import { transformer } from "@m5kdev/commons/utils/trpc";
|
|
3
|
+
import { createTRPCClient, httpBatchLink } from "@trpc/client";
|
|
4
|
+
import { serverUrl } from "../config";
|
|
5
|
+
|
|
6
|
+
export const trpcClient = createTRPCClient<BackendTRPCRouter>({
|
|
7
|
+
links: [
|
|
8
|
+
httpBatchLink({
|
|
9
|
+
url: `${serverUrl}/trpc`,
|
|
10
|
+
fetch(url, options) {
|
|
11
|
+
return fetch(url, {
|
|
12
|
+
...options,
|
|
13
|
+
body: options?.body ? (options.body as BodyInit | null | undefined) : undefined,
|
|
14
|
+
credentials: "include",
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
transformer,
|
|
18
|
+
}),
|
|
19
|
+
],
|
|
20
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="uniwind/types" />
|