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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +3 -25
- data/README.md +6 -17
- data/exe/gemstack +8 -1
- metadata +6 -159
- data/lib/gemstack/cli/add_generator.rb +0 -239
- data/lib/gemstack/cli/app_generator.rb +0 -142
- data/lib/gemstack/cli/commands/db.rb +0 -135
- data/lib/gemstack/cli/commands/jobs.rb +0 -96
- data/lib/gemstack/cli/controller_generator.rb +0 -73
- data/lib/gemstack/cli/deploy_generator.rb +0 -102
- data/lib/gemstack/cli/doctor.rb +0 -308
- data/lib/gemstack/cli/generator.rb +0 -144
- data/lib/gemstack/cli/job_generator.rb +0 -48
- data/lib/gemstack/cli/migration_generator.rb +0 -52
- data/lib/gemstack/cli/policy_generator.rb +0 -35
- data/lib/gemstack/cli/project.rb +0 -53
- data/lib/gemstack/cli/resource_generator.rb +0 -170
- data/lib/gemstack/cli/resource_spec.rb +0 -95
- data/lib/gemstack/cli.rb +0 -304
- data/templates/app/Gemfile.tt +0 -44
- data/templates/app/README.md.tt +0 -29
- data/templates/app/app/controllers/application_controller.rb +0 -6
- data/templates/app/app/jobs/application_job.rb +0 -9
- data/templates/app/app/mailers/application_mailer.rb +0 -8
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +0 -19
- data/templates/app/app/serializers/application_serializer.rb +0 -6
- data/templates/app/bin/gemstack +0 -7
- data/templates/app/config/app.rb.tt +0 -54
- data/templates/app/config/database.yml.tt +0 -61
- data/templates/app/config/environments/development.rb.tt +0 -35
- data/templates/app/config/environments/production.rb.tt +0 -40
- data/templates/app/config/environments/test.rb.tt +0 -21
- data/templates/app/config/puma.rb +0 -21
- data/templates/app/config/routes.rb +0 -10
- data/templates/app/config.ru +0 -6
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +0 -5
- data/templates/app/dot_env.example.tt +0 -19
- data/templates/app/dot_gitignore +0 -16
- data/templates/app/dot_node-version.tt +0 -1
- data/templates/app/dot_nvmrc.tt +0 -1
- data/templates/app/dot_ruby-version.tt +0 -1
- data/templates/app/dot_tool-versions.tt +0 -4
- data/templates/app/test/health_test.rb +0 -18
- data/templates/app/test/test_helper.rb.tt +0 -25
- data/templates/auth/app/controllers/api_tokens_controller.rb +0 -35
- data/templates/auth/app/controllers/email_verifications_controller.rb +0 -31
- data/templates/auth/app/controllers/password_resets_controller.rb +0 -40
- data/templates/auth/app/controllers/registrations_controller.rb +0 -19
- data/templates/auth/app/controllers/sessions_controller.rb +0 -29
- data/templates/auth/app/mailers/auth_mailer.rb +0 -20
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +0 -2
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +0 -2
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +0 -3
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +0 -6
- data/templates/auth/app/models/auth_token.rb +0 -15
- data/templates/auth/app/models/user.rb +0 -12
- data/templates/auth/app/policies/application_policy.rb +0 -6
- data/templates/auth/app/serializers/api_token_serializer.rb +0 -6
- data/templates/auth/app/serializers/new_api_token_serializer.rb +0 -9
- data/templates/auth/app/serializers/user_serializer.rb +0 -6
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +0 -41
- data/templates/auth/frontend/app/account/page.tsx +0 -116
- data/templates/auth/frontend/app/forgot-password/page.tsx +0 -48
- data/templates/auth/frontend/app/login/page.tsx +0 -26
- data/templates/auth/frontend/app/reset-password/page.tsx +0 -11
- data/templates/auth/frontend/app/signup/page.tsx +0 -27
- data/templates/auth/frontend/app/verify-email/page.tsx +0 -11
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +0 -58
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +0 -43
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +0 -28
- data/templates/auth/frontend/lib/auth.ts +0 -113
- data/templates/auth/test/controllers/auth_test.rb +0 -108
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +0 -10
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +0 -14
- data/templates/deploy/Caddyfile.tt +0 -19
- data/templates/deploy/Dockerfile.tt +0 -63
- data/templates/deploy/Procfile.tt +0 -6
- data/templates/deploy/compose.yaml.tt +0 -129
- data/templates/deploy/dot_dockerignore +0 -14
- data/templates/frontend/app/globals.css +0 -171
- data/templates/frontend/app/layout.tsx.tt +0 -19
- data/templates/frontend/app/page.module.css +0 -315
- data/templates/frontend/app/page.tsx.tt +0 -183
- data/templates/frontend/app/providers.tsx +0 -24
- data/templates/frontend/lib/gemstack/client.ts +0 -125
- data/templates/frontend/next-env.d.ts +0 -5
- data/templates/frontend/next.config.ts +0 -17
- data/templates/frontend/package.json.tt +0 -23
- data/templates/frontend/tsconfig.json +0 -21
- data/templates/job/app/jobs/%file_name%.rb.tt +0 -15
- data/templates/job/test/jobs/%file_name%_test.rb.tt +0 -15
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +0 -17
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +0 -21
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +0 -9
- data/templates/realtime/config/channels.rb +0 -14
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +0 -120
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +0 -51
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +0 -74
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +0 -45
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +0 -49
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +0 -27
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +0 -51
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +0 -15
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +0 -98
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +0 -36
- data/templates/resource/frontend/frontend/lib/format.ts +0 -12
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +0 -64
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +0 -18
- data/templates/resource/model/app/models/%file_name%.rb.tt +0 -15
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +0 -29
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +0 -7
- data/templates/storage/app/controllers/uploads_controller.rb.tt +0 -33
- data/templates/storage/app/serializers/upload_serializer.rb +0 -11
- data/templates/storage/frontend/lib/upload.ts +0 -52
- data/templates/storage/test/controllers/uploads_test.rb.tt +0 -30
|
@@ -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,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 <token></code>.
|
|
78
|
-
</p>
|
|
79
|
-
{create.data && (
|
|
80
|
-
<p className="status status-ok">
|
|
81
|
-
<span className="dot" />
|
|
82
|
-
Copy your new token now — it won'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
|