gemstack-cli 0.2.5 → 0.3.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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -25
  3. data/README.md +6 -17
  4. data/exe/gemstack +8 -1
  5. metadata +6 -159
  6. data/lib/gemstack/cli/add_generator.rb +0 -239
  7. data/lib/gemstack/cli/app_generator.rb +0 -142
  8. data/lib/gemstack/cli/commands/db.rb +0 -135
  9. data/lib/gemstack/cli/commands/jobs.rb +0 -96
  10. data/lib/gemstack/cli/controller_generator.rb +0 -73
  11. data/lib/gemstack/cli/deploy_generator.rb +0 -102
  12. data/lib/gemstack/cli/doctor.rb +0 -308
  13. data/lib/gemstack/cli/generator.rb +0 -144
  14. data/lib/gemstack/cli/job_generator.rb +0 -48
  15. data/lib/gemstack/cli/migration_generator.rb +0 -52
  16. data/lib/gemstack/cli/policy_generator.rb +0 -35
  17. data/lib/gemstack/cli/project.rb +0 -53
  18. data/lib/gemstack/cli/resource_generator.rb +0 -170
  19. data/lib/gemstack/cli/resource_spec.rb +0 -95
  20. data/lib/gemstack/cli.rb +0 -304
  21. data/templates/app/Gemfile.tt +0 -44
  22. data/templates/app/README.md.tt +0 -29
  23. data/templates/app/app/controllers/application_controller.rb +0 -6
  24. data/templates/app/app/jobs/application_job.rb +0 -9
  25. data/templates/app/app/mailers/application_mailer.rb +0 -8
  26. data/templates/app/app/mailers/templates/dot_keep +0 -0
  27. data/templates/app/app/models/application_model.rb +0 -19
  28. data/templates/app/app/serializers/application_serializer.rb +0 -6
  29. data/templates/app/bin/gemstack +0 -7
  30. data/templates/app/config/app.rb.tt +0 -54
  31. data/templates/app/config/database.yml.tt +0 -61
  32. data/templates/app/config/environments/development.rb.tt +0 -35
  33. data/templates/app/config/environments/production.rb.tt +0 -40
  34. data/templates/app/config/environments/test.rb.tt +0 -21
  35. data/templates/app/config/puma.rb +0 -21
  36. data/templates/app/config/routes.rb +0 -10
  37. data/templates/app/config.ru +0 -6
  38. data/templates/app/db/migrations/dot_keep +0 -0
  39. data/templates/app/db/seeds.rb +0 -5
  40. data/templates/app/dot_env.example.tt +0 -19
  41. data/templates/app/dot_gitignore +0 -16
  42. data/templates/app/dot_node-version.tt +0 -1
  43. data/templates/app/dot_nvmrc.tt +0 -1
  44. data/templates/app/dot_ruby-version.tt +0 -1
  45. data/templates/app/dot_tool-versions.tt +0 -4
  46. data/templates/app/test/health_test.rb +0 -18
  47. data/templates/app/test/test_helper.rb.tt +0 -25
  48. data/templates/auth/app/controllers/api_tokens_controller.rb +0 -35
  49. data/templates/auth/app/controllers/email_verifications_controller.rb +0 -31
  50. data/templates/auth/app/controllers/password_resets_controller.rb +0 -40
  51. data/templates/auth/app/controllers/registrations_controller.rb +0 -19
  52. data/templates/auth/app/controllers/sessions_controller.rb +0 -29
  53. data/templates/auth/app/mailers/auth_mailer.rb +0 -20
  54. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +0 -2
  55. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +0 -2
  56. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +0 -3
  57. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +0 -6
  58. data/templates/auth/app/models/auth_token.rb +0 -15
  59. data/templates/auth/app/models/user.rb +0 -12
  60. data/templates/auth/app/policies/application_policy.rb +0 -6
  61. data/templates/auth/app/serializers/api_token_serializer.rb +0 -6
  62. data/templates/auth/app/serializers/new_api_token_serializer.rb +0 -9
  63. data/templates/auth/app/serializers/user_serializer.rb +0 -6
  64. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +0 -41
  65. data/templates/auth/frontend/app/account/page.tsx +0 -116
  66. data/templates/auth/frontend/app/forgot-password/page.tsx +0 -48
  67. data/templates/auth/frontend/app/login/page.tsx +0 -26
  68. data/templates/auth/frontend/app/reset-password/page.tsx +0 -11
  69. data/templates/auth/frontend/app/signup/page.tsx +0 -27
  70. data/templates/auth/frontend/app/verify-email/page.tsx +0 -11
  71. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +0 -58
  72. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +0 -43
  73. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +0 -28
  74. data/templates/auth/frontend/lib/auth.ts +0 -113
  75. data/templates/auth/test/controllers/auth_test.rb +0 -108
  76. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +0 -10
  77. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +0 -14
  78. data/templates/deploy/Caddyfile.tt +0 -19
  79. data/templates/deploy/Dockerfile.tt +0 -63
  80. data/templates/deploy/Procfile.tt +0 -6
  81. data/templates/deploy/compose.yaml.tt +0 -129
  82. data/templates/deploy/dot_dockerignore +0 -14
  83. data/templates/frontend/app/globals.css +0 -171
  84. data/templates/frontend/app/layout.tsx.tt +0 -19
  85. data/templates/frontend/app/page.module.css +0 -315
  86. data/templates/frontend/app/page.tsx.tt +0 -183
  87. data/templates/frontend/app/providers.tsx +0 -24
  88. data/templates/frontend/lib/gemstack/client.ts +0 -125
  89. data/templates/frontend/next-env.d.ts +0 -5
  90. data/templates/frontend/next.config.ts +0 -17
  91. data/templates/frontend/package.json.tt +0 -23
  92. data/templates/frontend/tsconfig.json +0 -21
  93. data/templates/job/app/jobs/%file_name%.rb.tt +0 -15
  94. data/templates/job/test/jobs/%file_name%_test.rb.tt +0 -15
  95. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +0 -17
  96. data/templates/policy/app/policies/%file_name%_policy.rb.tt +0 -21
  97. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +0 -9
  98. data/templates/realtime/config/channels.rb +0 -14
  99. data/templates/realtime/frontend/lib/gemstack/realtime.ts +0 -120
  100. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +0 -51
  101. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +0 -74
  102. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +0 -45
  103. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +0 -49
  104. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +0 -27
  105. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +0 -51
  106. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +0 -15
  107. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +0 -98
  108. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +0 -36
  109. data/templates/resource/frontend/frontend/lib/format.ts +0 -12
  110. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +0 -64
  111. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +0 -18
  112. data/templates/resource/model/app/models/%file_name%.rb.tt +0 -15
  113. data/templates/resource/model/test/models/%file_name%_test.rb.tt +0 -29
  114. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +0 -7
  115. data/templates/storage/app/controllers/uploads_controller.rb.tt +0 -33
  116. data/templates/storage/app/serializers/upload_serializer.rb +0 -11
  117. data/templates/storage/frontend/lib/upload.ts +0 -52
  118. data/templates/storage/test/controllers/uploads_test.rb.tt +0 -30
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class ApiTokenSerializer < ApplicationSerializer
4
- model AuthToken
5
- attributes :id, :name, :last_used_at, :expires_at, :created_at
6
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # The response to creating an API token: the only time the token itself is shown.
4
- class NewApiTokenSerializer < ApplicationSerializer
5
- Result = Data.define(:token, :api_token)
6
-
7
- attribute :token, :string
8
- attribute :api_token, ApiTokenSerializer
9
- end
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # What the API shows about the signed-in user. Never add password_digest.
4
- class UserSerializer < ApplicationSerializer
5
- attributes :id, :email, :email_verified_at, :created_at
6
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Tables used by GemStack::Auth (docs/authentication.md).
4
- Sequel.migration do
5
- change do
6
- create_table(:users) do
7
- primary_key :id, type: :Bignum
8
- String :email, null: false, unique: true # stored lowercased
9
- String :password_digest, null: false # Argon2id
10
- column :email_verified_at, :timestamptz
11
- column :created_at, :timestamptz, null: false
12
- column :updated_at, :timestamptz, null: false
13
- end
14
-
15
- # One row per signed-in browser; the cookie holds a random token, the
16
- # table only its SHA-256 digest.
17
- create_table(:sessions) do
18
- primary_key :id, type: :Bignum
19
- foreign_key :user_id, :users, type: :Bignum, null: false, on_delete: :cascade, index: true
20
- String :token_digest, size: 64, null: false, unique: true
21
- column :ip, :inet
22
- String :user_agent
23
- column :created_at, :timestamptz, null: false
24
- column :last_seen_at, :timestamptz, null: false
25
- column :expires_at, :timestamptz, null: false, index: true
26
- end
27
-
28
- # API tokens, password reset and email verification tokens (digests only).
29
- create_table(:auth_tokens) do
30
- primary_key :id, type: :Bignum
31
- foreign_key :user_id, :users, type: :Bignum, null: false, on_delete: :cascade, index: true
32
- String :purpose, size: 30, null: false
33
- String :token_digest, size: 64, null: false, unique: true
34
- String :name
35
- String :email
36
- column :created_at, :timestamptz, null: false
37
- column :last_used_at, :timestamptz
38
- column :expires_at, :timestamptz
39
- end
40
- end
41
- end
@@ -1,116 +0,0 @@
1
- "use client";
2
-
3
- import Link from "next/link";
4
- import { useRouter } from "next/navigation";
5
- import { useState, type FormEvent } from "react";
6
- import {
7
- errorMessage,
8
- useApiTokens,
9
- useCreateApiToken,
10
- useCurrentUser,
11
- useLogout,
12
- useResendVerification,
13
- useRevokeApiToken,
14
- } from "@/lib/auth";
15
-
16
- export default function AccountPage() {
17
- const router = useRouter();
18
- const { data: user, isPending } = useCurrentUser();
19
- const logout = useLogout();
20
- const resend = useResendVerification();
21
-
22
- if (isPending) return <main className="page muted">Loading…</main>;
23
- if (!user) {
24
- return (
25
- <main className="page">
26
- <h1>Account</h1>
27
- <p>
28
- You are signed out. <Link href="/login">Log in</Link> or <Link href="/signup">sign up</Link>.
29
- </p>
30
- </main>
31
- );
32
- }
33
-
34
- return (
35
- <main className="page">
36
- <div className="page-header">
37
- <h1>Account</h1>
38
- <button className="button" onClick={() => logout.mutate(undefined, { onSuccess: () => router.push("/login") })}>
39
- Log out
40
- </button>
41
- </div>
42
- <dl className="card">
43
- <dt>Email</dt>
44
- <dd>
45
- {user.email}{" "}
46
- {user.email_verified_at ? (
47
- <span className="muted">(confirmed)</span>
48
- ) : (
49
- <button className="button" disabled={resend.isPending || resend.isSuccess} onClick={() => resend.mutate()}>
50
- {resend.isSuccess ? "Email sent" : "Resend confirmation email"}
51
- </button>
52
- )}
53
- </dd>
54
- <dt>Member since</dt>
55
- <dd>{new Date(user.created_at).toLocaleDateString()}</dd>
56
- </dl>
57
- <ApiTokens />
58
- </main>
59
- );
60
- }
61
-
62
- function ApiTokens() {
63
- const tokens = useApiTokens();
64
- const create = useCreateApiToken();
65
- const revoke = useRevokeApiToken();
66
- const [name, setName] = useState("");
67
-
68
- function handleSubmit(event: FormEvent<HTMLFormElement>) {
69
- event.preventDefault();
70
- create.mutate(name, { onSuccess: () => setName("") });
71
- }
72
-
73
- return (
74
- <section>
75
- <h2>API tokens</h2>
76
- <p className="muted">
77
- For scripts and other services: send <code>Authorization: Bearer &lt;token&gt;</code>.
78
- </p>
79
- {create.data && (
80
- <p className="status status-ok">
81
- <span className="dot" />
82
- Copy your new token now — it won&apos;t be shown again: <code>{create.data.token}</code>
83
- </p>
84
- )}
85
- <form className="form" onSubmit={handleSubmit}>
86
- <label className="field">
87
- Name
88
- <input required maxLength={100} value={name} onChange={(e) => setName(e.target.value)} placeholder="CI" />
89
- </label>
90
- {create.error && <p className="form-error">{errorMessage(create.error)}</p>}
91
- <div className="actions">
92
- <button className="button" type="submit" disabled={create.isPending}>
93
- Create token
94
- </button>
95
- </div>
96
- </form>
97
- <table className="table">
98
- <tbody>
99
- {tokens.data?.map((token) => (
100
- <tr key={token.id}>
101
- <td>{token.name}</td>
102
- <td className="muted">
103
- {token.last_used_at ? `used ${new Date(token.last_used_at).toLocaleString()}` : "never used"}
104
- </td>
105
- <td>
106
- <button className="button button-danger" onClick={() => revoke.mutate(token.id)}>
107
- Revoke
108
- </button>
109
- </td>
110
- </tr>
111
- ))}
112
- </tbody>
113
- </table>
114
- </section>
115
- );
116
- }
@@ -1,48 +0,0 @@
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
- }
@@ -1,26 +0,0 @@
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
- }
@@ -1,11 +0,0 @@
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
- }
@@ -1,27 +0,0 @@
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
- }
@@ -1,11 +0,0 @@
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
- }
@@ -1,58 +0,0 @@
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
- }
@@ -1,43 +0,0 @@
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
- }
@@ -1,28 +0,0 @@
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
- }
@@ -1,113 +0,0 @@
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
- }
@@ -1,108 +0,0 @@
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
@@ -1,10 +0,0 @@
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