gemstack-cli 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +24 -0
- data/exe/gemstack +6 -0
- data/lib/gemstack/cli/add_generator.rb +237 -0
- data/lib/gemstack/cli/app_generator.rb +110 -0
- data/lib/gemstack/cli/commands/db.rb +134 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +64 -0
- data/lib/gemstack/cli/doctor.rb +277 -0
- data/lib/gemstack/cli/generator.rb +122 -0
- data/lib/gemstack/cli/job_generator.rb +47 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +169 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +299 -0
- data/templates/app/Gemfile.tt +39 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +30 -0
- data/templates/app/config/environments/development.rb +6 -0
- data/templates/app/config/environments/production.rb +8 -0
- data/templates/app/config/environments/test.rb +7 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example +9 -0
- data/templates/app/dot_gitignore +14 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +1 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +62 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +80 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.tsx.tt +45 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +63 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +195 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useState, type FormEvent } from "react";
|
|
5
|
+
import { errorMessage, useForgotPassword } from "@/lib/auth";
|
|
6
|
+
|
|
7
|
+
export default function ForgotPasswordPage() {
|
|
8
|
+
const [email, setEmail] = useState("");
|
|
9
|
+
const forgot = useForgotPassword();
|
|
10
|
+
|
|
11
|
+
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
12
|
+
event.preventDefault();
|
|
13
|
+
forgot.mutate(email);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (forgot.isSuccess) {
|
|
17
|
+
return (
|
|
18
|
+
<main className="page">
|
|
19
|
+
<h1>Check your email</h1>
|
|
20
|
+
<p>If an account exists for {email}, we sent it a link to choose a new password.</p>
|
|
21
|
+
<Link href="/login">Back to log in</Link>
|
|
22
|
+
</main>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const message = errorMessage(forgot.error);
|
|
27
|
+
return (
|
|
28
|
+
<main className="page">
|
|
29
|
+
<h1>Reset your password</h1>
|
|
30
|
+
<form className="form" onSubmit={handleSubmit}>
|
|
31
|
+
<label className="field">
|
|
32
|
+
Email
|
|
33
|
+
<input type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
34
|
+
</label>
|
|
35
|
+
{message && (
|
|
36
|
+
<p className="form-error" role="alert">
|
|
37
|
+
{message}
|
|
38
|
+
</p>
|
|
39
|
+
)}
|
|
40
|
+
<div className="actions">
|
|
41
|
+
<button className="button" type="submit" disabled={forgot.isPending}>
|
|
42
|
+
Send reset link
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
</form>
|
|
46
|
+
</main>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { CredentialsForm } from "@/components/auth/CredentialsForm";
|
|
6
|
+
import { useLogin } from "@/lib/auth";
|
|
7
|
+
|
|
8
|
+
export default function LoginPage() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const login = useLogin();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<main className="page">
|
|
14
|
+
<h1>Log in</h1>
|
|
15
|
+
<CredentialsForm
|
|
16
|
+
submitLabel="Log in"
|
|
17
|
+
error={login.error}
|
|
18
|
+
pending={login.isPending}
|
|
19
|
+
onSubmit={(credentials) => login.mutate(credentials, { onSuccess: () => router.push("/account") })}
|
|
20
|
+
/>
|
|
21
|
+
<p className="muted">
|
|
22
|
+
<Link href="/forgot-password">Forgot your password?</Link> · No account? <Link href="/signup">Sign up</Link>
|
|
23
|
+
</p>
|
|
24
|
+
</main>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ResetPasswordForm } from "@/components/auth/ResetPasswordForm";
|
|
2
|
+
|
|
3
|
+
export default async function ResetPasswordPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
|
|
4
|
+
const { token } = await searchParams;
|
|
5
|
+
return (
|
|
6
|
+
<main className="page">
|
|
7
|
+
<h1>Choose a new password</h1>
|
|
8
|
+
{token ? <ResetPasswordForm token={token} /> : <p className="form-error">This link is missing its token.</p>}
|
|
9
|
+
</main>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { CredentialsForm } from "@/components/auth/CredentialsForm";
|
|
6
|
+
import { useSignup } from "@/lib/auth";
|
|
7
|
+
|
|
8
|
+
export default function SignupPage() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const signup = useSignup();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<main className="page">
|
|
14
|
+
<h1>Create an account</h1>
|
|
15
|
+
<CredentialsForm
|
|
16
|
+
submitLabel="Sign up"
|
|
17
|
+
newPassword
|
|
18
|
+
error={signup.error}
|
|
19
|
+
pending={signup.isPending}
|
|
20
|
+
onSubmit={(credentials) => signup.mutate(credentials, { onSuccess: () => router.push("/account") })}
|
|
21
|
+
/>
|
|
22
|
+
<p className="muted">
|
|
23
|
+
Already have an account? <Link href="/login">Log in</Link>
|
|
24
|
+
</p>
|
|
25
|
+
</main>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { VerifyEmail } from "@/components/auth/VerifyEmail";
|
|
2
|
+
|
|
3
|
+
export default async function VerifyEmailPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
|
|
4
|
+
const { token } = await searchParams;
|
|
5
|
+
return (
|
|
6
|
+
<main className="page">
|
|
7
|
+
<h1>Confirm your email</h1>
|
|
8
|
+
{token ? <VerifyEmail token={token} /> : <p className="form-error">This link is missing its token.</p>}
|
|
9
|
+
</main>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from "react";
|
|
4
|
+
import { errorMessage, type Credentials } from "@/lib/auth";
|
|
5
|
+
|
|
6
|
+
export function CredentialsForm({
|
|
7
|
+
submitLabel,
|
|
8
|
+
newPassword = false,
|
|
9
|
+
error,
|
|
10
|
+
pending,
|
|
11
|
+
onSubmit,
|
|
12
|
+
}: {
|
|
13
|
+
submitLabel: string;
|
|
14
|
+
newPassword?: boolean;
|
|
15
|
+
error: unknown;
|
|
16
|
+
pending: boolean;
|
|
17
|
+
onSubmit: (credentials: Credentials) => void;
|
|
18
|
+
}) {
|
|
19
|
+
const [email, setEmail] = useState("");
|
|
20
|
+
const [password, setPassword] = useState("");
|
|
21
|
+
|
|
22
|
+
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
onSubmit({ email, password });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const message = errorMessage(error);
|
|
28
|
+
return (
|
|
29
|
+
<form className="form" onSubmit={handleSubmit}>
|
|
30
|
+
<label className="field">
|
|
31
|
+
Email
|
|
32
|
+
<input type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
33
|
+
</label>
|
|
34
|
+
<label className="field">
|
|
35
|
+
Password
|
|
36
|
+
<input
|
|
37
|
+
type="password"
|
|
38
|
+
autoComplete={newPassword ? "new-password" : "current-password"}
|
|
39
|
+
minLength={newPassword ? 12 : undefined}
|
|
40
|
+
required
|
|
41
|
+
value={password}
|
|
42
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
43
|
+
/>
|
|
44
|
+
{newPassword && <small className="muted">At least 12 characters. A few random words work well.</small>}
|
|
45
|
+
</label>
|
|
46
|
+
{message && (
|
|
47
|
+
<p className="form-error" role="alert">
|
|
48
|
+
{message}
|
|
49
|
+
</p>
|
|
50
|
+
)}
|
|
51
|
+
<div className="actions">
|
|
52
|
+
<button className="button" type="submit" disabled={pending}>
|
|
53
|
+
{pending ? "Please wait…" : submitLabel}
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</form>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState, type FormEvent } from "react";
|
|
5
|
+
import { errorMessage, useResetPassword } from "@/lib/auth";
|
|
6
|
+
|
|
7
|
+
export function ResetPasswordForm({ token }: { token: string }) {
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const [password, setPassword] = useState("");
|
|
10
|
+
const reset = useResetPassword();
|
|
11
|
+
|
|
12
|
+
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
reset.mutate({ token, password }, { onSuccess: () => router.push("/account") });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const message = errorMessage(reset.error);
|
|
18
|
+
return (
|
|
19
|
+
<form className="form" onSubmit={handleSubmit}>
|
|
20
|
+
<label className="field">
|
|
21
|
+
New password
|
|
22
|
+
<input
|
|
23
|
+
type="password"
|
|
24
|
+
autoComplete="new-password"
|
|
25
|
+
minLength={12}
|
|
26
|
+
required
|
|
27
|
+
value={password}
|
|
28
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
29
|
+
/>
|
|
30
|
+
</label>
|
|
31
|
+
{message && (
|
|
32
|
+
<p className="form-error" role="alert">
|
|
33
|
+
{message}
|
|
34
|
+
</p>
|
|
35
|
+
)}
|
|
36
|
+
<div className="actions">
|
|
37
|
+
<button className="button" type="submit" disabled={reset.isPending}>
|
|
38
|
+
Save password
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</form>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import { errorMessage, useVerifyEmail } from "@/lib/auth";
|
|
6
|
+
|
|
7
|
+
// Confirms on load. Links in emails are opened with GET, so the page posts
|
|
8
|
+
// the token — a mail scanner prefetching the link can't use it up.
|
|
9
|
+
export function VerifyEmail({ token }: { token: string }) {
|
|
10
|
+
const verify = useVerifyEmail();
|
|
11
|
+
const started = useRef(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (started.current) return;
|
|
15
|
+
started.current = true;
|
|
16
|
+
verify.mutate(token);
|
|
17
|
+
}, [token, verify]);
|
|
18
|
+
|
|
19
|
+
if (verify.isSuccess) {
|
|
20
|
+
return (
|
|
21
|
+
<p>
|
|
22
|
+
Your email address is confirmed. <Link href="/account">Continue</Link>
|
|
23
|
+
</p>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
if (verify.isError) return <p className="form-error">{errorMessage(verify.error)}</p>;
|
|
27
|
+
return <p className="muted">Confirming…</p>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Authentication hooks (generated by `gemstack add auth`). The session lives
|
|
4
|
+
// in an HttpOnly cookie the browser sends automatically — JavaScript never
|
|
5
|
+
// sees it, so there is no token to store or leak.
|
|
6
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import { ApiError } from "@/lib/gemstack/client";
|
|
8
|
+
import { apiTokens, emailVerifications, passwordResets, registrations, sessions } from "@/lib/api/generated";
|
|
9
|
+
import type { SessionCreateInput, User } from "@/lib/api/generated";
|
|
10
|
+
|
|
11
|
+
export const authKeys = { me: ["auth", "me"] as const, tokens: ["auth", "tokens"] as const };
|
|
12
|
+
|
|
13
|
+
export type Credentials = SessionCreateInput;
|
|
14
|
+
|
|
15
|
+
/** The signed-in user, or null when signed out. */
|
|
16
|
+
export function useCurrentUser() {
|
|
17
|
+
return useQuery({
|
|
18
|
+
queryKey: authKeys.me,
|
|
19
|
+
queryFn: async () => {
|
|
20
|
+
try {
|
|
21
|
+
return await sessions.get();
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof ApiError && error.status === 401) return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
staleTime: 60_000,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function useSignedIn() {
|
|
32
|
+
const queryClient = useQueryClient();
|
|
33
|
+
return (user: User) => {
|
|
34
|
+
queryClient.setQueryData(authKeys.me, user);
|
|
35
|
+
// Anything cached belonged to the previous (or no) user.
|
|
36
|
+
return queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] !== "auth" });
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useLogin() {
|
|
41
|
+
const signedIn = useSignedIn();
|
|
42
|
+
return useMutation({ mutationFn: (input: Credentials) => sessions.create(input), onSuccess: signedIn });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useSignup() {
|
|
46
|
+
const signedIn = useSignedIn();
|
|
47
|
+
return useMutation({ mutationFn: (input: Credentials) => registrations.create(input), onSuccess: signedIn });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useLogout() {
|
|
51
|
+
const queryClient = useQueryClient();
|
|
52
|
+
return useMutation({
|
|
53
|
+
mutationFn: () => sessions.delete(),
|
|
54
|
+
onSuccess: () => {
|
|
55
|
+
queryClient.clear();
|
|
56
|
+
queryClient.setQueryData(authKeys.me, null);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useForgotPassword() {
|
|
62
|
+
return useMutation({ mutationFn: (email: string) => passwordResets.create({ email }) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useResetPassword() {
|
|
66
|
+
const signedIn = useSignedIn();
|
|
67
|
+
return useMutation({
|
|
68
|
+
mutationFn: (input: { token: string; password: string }) => passwordResets.update(input),
|
|
69
|
+
onSuccess: signedIn,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useVerifyEmail() {
|
|
74
|
+
const queryClient = useQueryClient();
|
|
75
|
+
return useMutation({
|
|
76
|
+
mutationFn: (token: string) => emailVerifications.update({ token }),
|
|
77
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: authKeys.me }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useResendVerification() {
|
|
82
|
+
return useMutation({ mutationFn: () => emailVerifications.create() });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useApiTokens() {
|
|
86
|
+
return useQuery({ queryKey: authKeys.tokens, queryFn: () => apiTokens.list() });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function useCreateApiToken() {
|
|
90
|
+
const queryClient = useQueryClient();
|
|
91
|
+
return useMutation({
|
|
92
|
+
mutationFn: (name: string) => apiTokens.create({ name }),
|
|
93
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: authKeys.tokens }),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function useRevokeApiToken() {
|
|
98
|
+
const queryClient = useQueryClient();
|
|
99
|
+
return useMutation({
|
|
100
|
+
mutationFn: (id: number) => apiTokens.delete(id),
|
|
101
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: authKeys.tokens }),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A readable message for a failed auth request. */
|
|
106
|
+
export function errorMessage(error: unknown): string | null {
|
|
107
|
+
if (!error) return null;
|
|
108
|
+
if (error instanceof ApiError) {
|
|
109
|
+
const fields = Object.entries(error.errors).map(([field, messages]) => `${field} ${messages.join(", ")}`);
|
|
110
|
+
return fields.length ? fields.join(". ") : error.message;
|
|
111
|
+
}
|
|
112
|
+
return "Something went wrong. Please try again.";
|
|
113
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../test_helper"
|
|
4
|
+
|
|
5
|
+
# The authentication flows generated by `gemstack add auth`.
|
|
6
|
+
class AuthTest < GemStack::TestCase
|
|
7
|
+
PASSWORD = "correct horse battery staple"
|
|
8
|
+
|
|
9
|
+
def create_user(email = "ada@example.com") = User.create(email: email, password: PASSWORD)
|
|
10
|
+
def token_from(mail) = mail.text_part.decoded[/token=([\w-]+)/, 1]
|
|
11
|
+
|
|
12
|
+
def test_signup_signs_in_and_sends_a_verification_email
|
|
13
|
+
assert_emails(1) { post_json "/api/auth/signup", { email: "Ada@Example.com", password: PASSWORD } }
|
|
14
|
+
|
|
15
|
+
assert_status 201
|
|
16
|
+
assert_equal "ada@example.com", json_body["email"]
|
|
17
|
+
refute json_body.key?("password_digest")
|
|
18
|
+
assert_equal ["ada@example.com"], last_email.to
|
|
19
|
+
get_json "/api/auth/me"
|
|
20
|
+
|
|
21
|
+
assert_status 200
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_signup_validation
|
|
25
|
+
create_user
|
|
26
|
+
post_json "/api/auth/signup", { email: "ada@example.com", password: "short" }
|
|
27
|
+
|
|
28
|
+
assert_error 422, "validation_failed"
|
|
29
|
+
assert_equal ["is already taken"], json_body["errors"]["email"]
|
|
30
|
+
assert_equal ["is too short (minimum 12 characters)"], json_body["errors"]["password"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_login_me_logout
|
|
34
|
+
user = create_user
|
|
35
|
+
post_json "/api/auth/login", { email: "ADA@example.com", password: PASSWORD }
|
|
36
|
+
|
|
37
|
+
assert_status 200
|
|
38
|
+
get_json "/api/auth/me"
|
|
39
|
+
|
|
40
|
+
assert_equal user.id, json_body["id"]
|
|
41
|
+
delete_json "/api/auth/logout"
|
|
42
|
+
|
|
43
|
+
assert_status 204
|
|
44
|
+
get_json "/api/auth/me"
|
|
45
|
+
|
|
46
|
+
assert_error 401, "unauthenticated"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_login_failures_look_the_same
|
|
50
|
+
create_user
|
|
51
|
+
post_json "/api/auth/login", { email: "ada@example.com", password: "wrong password!" }
|
|
52
|
+
wrong_password = json_body
|
|
53
|
+
post_json "/api/auth/login", { email: "nobody@example.com", password: PASSWORD }
|
|
54
|
+
|
|
55
|
+
assert_error 401, "invalid_credentials"
|
|
56
|
+
assert_equal wrong_password["error"]["message"], json_body["error"]["message"]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_password_reset
|
|
60
|
+
user = create_user
|
|
61
|
+
other_device = GemStack::Auth::Sessions.create(user.id)
|
|
62
|
+
assert_emails(1) { post_json "/api/auth/password/forgot", { email: user.email } }
|
|
63
|
+
|
|
64
|
+
assert_status 202
|
|
65
|
+
post_json "/api/auth/password/reset", { token: token_from(last_email), password: "a brand new passphrase" }
|
|
66
|
+
|
|
67
|
+
assert_status 200
|
|
68
|
+
assert User.authenticate_by(email: user.email, password: "a brand new passphrase")
|
|
69
|
+
assert_nil GemStack::Auth::Sessions.find(other_device), "other sessions end"
|
|
70
|
+
post_json "/api/auth/password/reset", { token: token_from(last_email), password: "and another one!!" }
|
|
71
|
+
|
|
72
|
+
assert_error 400, "invalid_token"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_forgot_password_does_not_reveal_accounts
|
|
76
|
+
assert_emails(0) { post_json "/api/auth/password/forgot", { email: "nobody@example.com" } }
|
|
77
|
+
|
|
78
|
+
assert_status 202
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_email_verification
|
|
82
|
+
user = create_user
|
|
83
|
+
sign_in_as(user)
|
|
84
|
+
assert_emails(1) { post_json "/api/auth/email/resend" }
|
|
85
|
+
post_json "/api/auth/email/verify", { token: token_from(last_email) }
|
|
86
|
+
|
|
87
|
+
assert_status 204
|
|
88
|
+
assert User[user.id].email_verified?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_api_tokens
|
|
92
|
+
user = create_user
|
|
93
|
+
sign_in_as(user)
|
|
94
|
+
post_json "/api/auth/tokens", { name: "CI" }
|
|
95
|
+
|
|
96
|
+
assert_status 201
|
|
97
|
+
token = json_body["token"]
|
|
98
|
+
get_json "/api/auth/me", {}, { "HTTP_AUTHORIZATION" => "Bearer #{token}" }
|
|
99
|
+
|
|
100
|
+
assert_equal user.id, json_body["id"]
|
|
101
|
+
get_json "/api/auth/tokens"
|
|
102
|
+
|
|
103
|
+
assert_equal ["CI"], json_body.map { |t| t["name"] }
|
|
104
|
+
delete_json "/api/auth/tokens/#{json_body.first["id"]}"
|
|
105
|
+
|
|
106
|
+
assert_status 204
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= class_name %> < ApplicationController
|
|
4
|
+
<% actions.each_with_index do |action, index| -%>
|
|
5
|
+
<%= "\n" if index.positive? %> # <%= route_for(action)[0].upcase %> /api<%= route_for(action)[1] %>
|
|
6
|
+
def <%= action %>
|
|
7
|
+
render({ action: "<%= file_name %>#<%= action %>" })
|
|
8
|
+
end
|
|
9
|
+
<% end -%>
|
|
10
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "<%= "../" * (file_name.count("/") + 1) %>test_helper"
|
|
4
|
+
|
|
5
|
+
class <%= class_name %>Test < GemStack::TestCase
|
|
6
|
+
<% actions.each_with_index do |action, index| -%>
|
|
7
|
+
<%= "\n" if index.positive? %> def test_<%= action %>
|
|
8
|
+
<%= route_for(action)[0] %>_json "<%= request_path(action) %>"
|
|
9
|
+
|
|
10
|
+
assert_status 200
|
|
11
|
+
assert_equal({ "action" => "<%= file_name %>#<%= action %>" }, json_body)
|
|
12
|
+
end
|
|
13
|
+
<% end -%>
|
|
14
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# One public origin: /api/* → Ruby, everything else → Next.js (docs/deployment.md).
|
|
2
|
+
# SITE_ADDRESS=example.com gets a Let's Encrypt certificate automatically;
|
|
3
|
+
# "localhost" uses Caddy's local CA (trust it with `caddy trust`, or curl -k).
|
|
4
|
+
{$SITE_ADDRESS:localhost} {
|
|
5
|
+
encode zstd gzip
|
|
6
|
+
|
|
7
|
+
handle /api/* {
|
|
8
|
+
reverse_proxy api:4000 {
|
|
9
|
+
# Server-Sent Events: stream responses immediately.
|
|
10
|
+
flush_interval -1
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
<% if frontend? -%>
|
|
14
|
+
|
|
15
|
+
handle {
|
|
16
|
+
reverse_proxy web:3000
|
|
17
|
+
}
|
|
18
|
+
<% end -%>
|
|
19
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
# Production images for <%= app_name %> (gemstack generate deploy — docs/deployment.md).
|
|
3
|
+
#
|
|
4
|
+
# docker build --target api -t <%= app_name %>-api . # Ruby API (Puma), also runs jobs and migrations
|
|
5
|
+
<% if frontend? -%>
|
|
6
|
+
# docker build --target web -t <%= app_name %>-web . # Next.js
|
|
7
|
+
<% end -%>
|
|
8
|
+
#
|
|
9
|
+
# Configuration comes from the environment at runtime (SECRET_KEY_BASE,
|
|
10
|
+
# DATABASE_URL, …); nothing secret is baked into the images.
|
|
11
|
+
|
|
12
|
+
ARG RUBY_VERSION=<%= ruby_version %>
|
|
13
|
+
ARG NODE_VERSION=<%= node_version %>
|
|
14
|
+
|
|
15
|
+
# ── Ruby API ────────────────────────────────────────────────────────────────
|
|
16
|
+
FROM ruby:${RUBY_VERSION}-slim AS api-build
|
|
17
|
+
RUN apt-get update -qq && apt-get install --no-install-recommends -y build-essential libpq-dev libyaml-dev git \
|
|
18
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
19
|
+
WORKDIR /app
|
|
20
|
+
ENV BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle
|
|
21
|
+
COPY Gemfile Gemfile.lock ./
|
|
22
|
+
COPY vendor/ ./vendor/
|
|
23
|
+
RUN bundle install --jobs 4 && rm -rf /usr/local/bundle/cache "${BUNDLE_PATH}"/ruby/*/cache
|
|
24
|
+
COPY . .
|
|
25
|
+
RUN rm -rf frontend tmp/* log/* .env*
|
|
26
|
+
|
|
27
|
+
FROM ruby:${RUBY_VERSION}-slim AS api
|
|
28
|
+
RUN apt-get update -qq && apt-get install --no-install-recommends -y libpq5 libyaml-0-2 curl \
|
|
29
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
30
|
+
&& groupadd --system --gid 1000 app && useradd --system --uid 1000 --gid app --create-home app
|
|
31
|
+
WORKDIR /app
|
|
32
|
+
ENV GEMSTACK_ENV=production BUNDLE_DEPLOYMENT=1 BUNDLE_WITHOUT="development:test" BUNDLE_PATH=/usr/local/bundle \
|
|
33
|
+
GEMSTACK_API_HOST=0.0.0.0 GEMSTACK_API_PORT=4000
|
|
34
|
+
COPY --from=api-build /usr/local/bundle /usr/local/bundle
|
|
35
|
+
COPY --from=api-build --chown=app:app /app /app
|
|
36
|
+
USER app
|
|
37
|
+
EXPOSE 4000
|
|
38
|
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s CMD curl -fsS http://127.0.0.1:4000/api/health || exit 1
|
|
39
|
+
# Other processes use the same image: `bundle exec gemstack jobs`, `bundle exec gemstack db:migrate`.
|
|
40
|
+
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
|
|
41
|
+
<% if frontend? -%>
|
|
42
|
+
|
|
43
|
+
# ── Next.js ─────────────────────────────────────────────────────────────────
|
|
44
|
+
FROM node:${NODE_VERSION}-slim AS web-build
|
|
45
|
+
WORKDIR /app
|
|
46
|
+
COPY frontend/package.json frontend/package-lock.json ./
|
|
47
|
+
RUN npm ci
|
|
48
|
+
COPY frontend/ ./
|
|
49
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
50
|
+
# GEMSTACK_API_URL is only needed here when Next.js proxies /api itself (no reverse proxy in front).
|
|
51
|
+
ARG GEMSTACK_API_URL
|
|
52
|
+
ENV GEMSTACK_API_URL=${GEMSTACK_API_URL}
|
|
53
|
+
RUN npm run build && npm prune --omit=dev
|
|
54
|
+
|
|
55
|
+
FROM node:${NODE_VERSION}-slim AS web
|
|
56
|
+
WORKDIR /app
|
|
57
|
+
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000
|
|
58
|
+
COPY --from=web-build --chown=node:node /app ./
|
|
59
|
+
USER node
|
|
60
|
+
EXPOSE 3000
|
|
61
|
+
CMD ["npm", "start"]
|
|
62
|
+
<% end -%>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Process types for Procfile platforms (Heroku, Render, Railway, Dokku…) — docs/deployment.md.
|
|
2
|
+
release: bundle exec gemstack db:migrate
|
|
3
|
+
web: GEMSTACK_API_PORT=${PORT:-4000} bundle exec puma -C config/puma.rb
|
|
4
|
+
<% if jobs? -%>
|
|
5
|
+
worker: bundle exec gemstack jobs
|
|
6
|
+
<% end -%>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Runs <%= app_name %> in production mode on this machine, behind Caddy on https://localhost:
|
|
2
|
+
#
|
|
3
|
+
# export SECRET_KEY_BASE=$(openssl rand -hex 64) POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
|
4
|
+
# docker compose up --build
|
|
5
|
+
#
|
|
6
|
+
# It's also a map of what a real deploy needs: one image per process type,
|
|
7
|
+
# migrations before the API starts, and a proxy sending /api to Ruby.
|
|
8
|
+
name: <%= app_name.tr("_", "-") %>
|
|
9
|
+
|
|
10
|
+
x-api-env: &api-env
|
|
11
|
+
GEMSTACK_ENV: production
|
|
12
|
+
SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE (openssl rand -hex 64)}
|
|
13
|
+
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/app
|
|
14
|
+
APP_URL: ${APP_URL:-https://localhost}
|
|
15
|
+
<% if mail? -%>
|
|
16
|
+
SMTP_URL: ${SMTP_URL:-} # e.g. smtp://user:pass@smtp.example.com:587 — emails retry until it is set
|
|
17
|
+
MAIL_FROM: ${MAIL_FROM:-}
|
|
18
|
+
<% end -%>
|
|
19
|
+
|
|
20
|
+
services:
|
|
21
|
+
db:
|
|
22
|
+
image: postgres:17
|
|
23
|
+
environment:
|
|
24
|
+
POSTGRES_USER: app
|
|
25
|
+
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
26
|
+
POSTGRES_DB: app
|
|
27
|
+
volumes: [db:/var/lib/postgresql/data]
|
|
28
|
+
healthcheck:
|
|
29
|
+
test: ["CMD-SHELL", "pg_isready -U app -d app"]
|
|
30
|
+
interval: 2s
|
|
31
|
+
retries: 30
|
|
32
|
+
|
|
33
|
+
migrate:
|
|
34
|
+
image: <%= app_name %>-api
|
|
35
|
+
build: { context: ., target: api }
|
|
36
|
+
healthcheck: { disable: true }
|
|
37
|
+
command: ["bundle", "exec", "gemstack", "db:migrate"]
|
|
38
|
+
environment: *api-env
|
|
39
|
+
depends_on: { db: { condition: service_healthy } }
|
|
40
|
+
|
|
41
|
+
api:
|
|
42
|
+
image: <%= app_name %>-api
|
|
43
|
+
build: { context: ., target: api }
|
|
44
|
+
environment:
|
|
45
|
+
<<: *api-env
|
|
46
|
+
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-2}
|
|
47
|
+
depends_on: { migrate: { condition: service_completed_successfully } }
|
|
48
|
+
<% if jobs? -%>
|
|
49
|
+
|
|
50
|
+
jobs:
|
|
51
|
+
image: <%= app_name %>-api
|
|
52
|
+
build: { context: ., target: api }
|
|
53
|
+
healthcheck: { disable: true }
|
|
54
|
+
command: ["bundle", "exec", "gemstack", "jobs"]
|
|
55
|
+
environment: *api-env
|
|
56
|
+
depends_on: { migrate: { condition: service_completed_successfully } }
|
|
57
|
+
<% end -%>
|
|
58
|
+
<% if frontend? -%>
|
|
59
|
+
|
|
60
|
+
web:
|
|
61
|
+
image: <%= app_name %>-web
|
|
62
|
+
build: { context: ., target: web }
|
|
63
|
+
environment:
|
|
64
|
+
GEMSTACK_API_URL: http://api:4000 # Server Components call the API directly
|
|
65
|
+
depends_on: [api]
|
|
66
|
+
<% end -%>
|
|
67
|
+
|
|
68
|
+
proxy:
|
|
69
|
+
image: caddy:2
|
|
70
|
+
ports: ["80:80", "443:443"]
|
|
71
|
+
environment:
|
|
72
|
+
SITE_ADDRESS: ${SITE_ADDRESS:-localhost}
|
|
73
|
+
volumes:
|
|
74
|
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
75
|
+
- caddy:/data
|
|
76
|
+
depends_on: [api<%= ", web" if frontend? %>]
|
|
77
|
+
|
|
78
|
+
volumes:
|
|
79
|
+
db:
|
|
80
|
+
caddy:
|