create-feltkit 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/README.md +23 -22
- package/template/feltkit/app.ts +3 -0
- package/template/package.json +1 -1
- package/template/src/App.tsx +159 -31
- package/template/src/styles.css +5 -0
- package/template-managed/package.json +2 -2
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -226,28 +226,22 @@ without their FeltDB settings.
|
|
|
226
226
|
|
|
227
227
|
Open <http://localhost:3000>.
|
|
228
228
|
|
|
229
|
-
4. **Sign in.** Production has no development sign-in (Ada and Grace are for development).
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
one-off container on the same volume while the app's container is stopped:
|
|
243
|
-
|
|
244
|
-
```bash
|
|
245
|
-
docker run --rm -v tasks-data:/data tasks node node_modules/@feltdb/feltkit/bin/feltkit.js session create --env production --type user --id you --role member
|
|
246
|
-
```
|
|
229
|
+
4. **Sign in.** Production has no development sign-in (Ada and Grace are for development). The
|
|
230
|
+
app has built-in accounts (`auth: { accounts: { roles: ["member"] } }` in `feltkit/app.ts`):
|
|
231
|
+
open it and create your account with an e-mail address and a password; anyone you share the
|
|
232
|
+
link with can do the same, and a new account is a member. Passwords are stored only as a
|
|
233
|
+
scrypt hash, in the app's own FeltDB. To close sign-up once your people have accounts, set
|
|
234
|
+
`signUp: false`. To give someone admin, or to use your own login instead, see
|
|
235
|
+
`auth.authenticate` and production Studio below.
|
|
236
|
+
|
|
237
|
+
The sign-in screen grows with configuration: add `email: { from: "auth@yourdomain" }` to
|
|
238
|
+
`auth.accounts` for "Forgot your password?" and address verification (development prints each
|
|
239
|
+
e-mail; production sends through your Mailerport service, `MAILPORT_URL` and
|
|
240
|
+
`MAILPORT_API_KEY`), and `auth.providers` for "Continue with Google / GitHub" (client id in
|
|
241
|
+
`feltkit/app.ts`, client secret as `FELTKIT_CREDENTIAL_<ID>_CLIENT_SECRET`).
|
|
247
242
|
|
|
248
243
|
**On AppBoundry** (`npx feltkit build --target appboundry`, then `appboundry run tasks.app`),
|
|
249
|
-
|
|
250
|
-
CLI. AppBoundry's operators create users with your app's roles and issue their sessions:
|
|
244
|
+
AppBoundry's operators create users with your app's roles and issue their sessions:
|
|
251
245
|
|
|
252
246
|
```bash
|
|
253
247
|
appboundry admin users add you --role member --state <installation>
|
|
@@ -256,8 +250,15 @@ without their FeltDB settings.
|
|
|
256
250
|
|
|
257
251
|
See AppBoundry's production operations guide for bootstrapping the first operator.
|
|
258
252
|
|
|
259
|
-
|
|
260
|
-
|
|
253
|
+
Operators and scripts can still get a session token without an account:
|
|
254
|
+
`npx feltkit session create --env production --type user --id <id> --role <role>` in the
|
|
255
|
+
project's folder (it uses the deployment's FeltDB settings from `.env.local`). For the
|
|
256
|
+
one-container Docker run, use a one-off container on the same volume while the app's container
|
|
257
|
+
is stopped:
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
docker run --rm -v tasks-data:/data tasks node node_modules/@feltdb/feltkit/bin/feltkit.js session create --env production --type user --id you --role member
|
|
261
|
+
```
|
|
261
262
|
|
|
262
263
|
5. **Manage it.** On Vercel, production Studio is on (at `/_feltkit/` of your app). Run
|
|
263
264
|
`npx feltkit manage` for a one-time sign-in link, or use **Manage in production Studio** on
|
package/template/feltkit/app.ts
CHANGED
|
@@ -26,6 +26,9 @@ export const app = feltkit({
|
|
|
26
26
|
// feltkit.operations.*: in production, admins are Studio's operators (npx feltkit operator grant).
|
|
27
27
|
admin: ["intelligence.*", "credential.*", "configuration.*", "apikeys.*", "webhooks.*", "jobs.*", "schedules.*", "feltkit.operations.*"],
|
|
28
28
|
},
|
|
29
|
+
// People sign up and sign in with an e-mail address and a password (stored in this app's FeltDB;
|
|
30
|
+
// only a hash of the password). A new account is a member.
|
|
31
|
+
auth: { accounts: { roles: ["member"] } },
|
|
29
32
|
// Development-only identities. Sign in as them from the app or the dashboard.
|
|
30
33
|
dev: {
|
|
31
34
|
users: [
|
package/template/package.json
CHANGED
package/template/src/App.tsx
CHANGED
|
@@ -19,7 +19,7 @@ function describe(error: unknown): string {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function App() {
|
|
22
|
-
const { session, signInAsDevUser, signOut } = useSession();
|
|
22
|
+
const { session, signInAsDevUser, signIn, signUp, signOut } = useSession();
|
|
23
23
|
const live = useConnectionState();
|
|
24
24
|
const [notice, setNotice] = useState<Notice>(null);
|
|
25
25
|
const principal = session?.principal ?? null;
|
|
@@ -44,13 +44,24 @@ export function App() {
|
|
|
44
44
|
|
|
45
45
|
const report = (error: unknown) => setNotice({ tone: "error", text: describe(error) });
|
|
46
46
|
|
|
47
|
+
// A link back into the app: a provider sign-in, a password reset, or an e-mail verification.
|
|
48
|
+
const client = useFeltKit();
|
|
49
|
+
const [resetToken, setResetToken] = useState<string | null>(null);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
void client.handleRedirect().then((r) => {
|
|
52
|
+
if (r?.kind === "reset") setResetToken(r.token);
|
|
53
|
+
else if (r?.kind === "verified") setNotice({ tone: "info", text: `${r.email} is verified.` });
|
|
54
|
+
else if (r?.kind === "error" || r?.kind === "verify-failed") setNotice({ tone: "error", text: r.message });
|
|
55
|
+
});
|
|
56
|
+
}, [client]);
|
|
57
|
+
|
|
47
58
|
return (
|
|
48
59
|
<div className="shell">
|
|
49
60
|
<header className="topbar">
|
|
50
61
|
<div className="brand">
|
|
51
62
|
<img className="mark" src="/feltkit-mark.png" width="30" height="30" alt="" />
|
|
52
63
|
<span className="brand-name">Tasks</span>
|
|
53
|
-
{session &&
|
|
64
|
+
{session && !session.environment.production && <span className="env">{session.environment.name}</span>}
|
|
54
65
|
</div>
|
|
55
66
|
<div className="topbar-right">
|
|
56
67
|
<span className={`live ${live ? "on" : ""}`} title={live ? "Live: changes from anyone appear here immediately" : "Connecting to the change stream…"}>
|
|
@@ -73,6 +84,11 @@ export function App() {
|
|
|
73
84
|
</select>
|
|
74
85
|
</label>
|
|
75
86
|
)}
|
|
87
|
+
{principal && !session?.devUsers.length && (
|
|
88
|
+
<button className="ghost signout" onClick={() => void signOut().catch(report)} title={principal.displayName ?? principal.id}>
|
|
89
|
+
Sign out
|
|
90
|
+
</button>
|
|
91
|
+
)}
|
|
76
92
|
<ThemeToggle />
|
|
77
93
|
{session?.dashboard && (
|
|
78
94
|
<a className="dashboard-link" href={session.dashboard} target="_blank" rel="noreferrer">
|
|
@@ -93,68 +109,180 @@ export function App() {
|
|
|
93
109
|
|
|
94
110
|
{!session ? (
|
|
95
111
|
<div className="center muted">Connecting…</div>
|
|
112
|
+
) : resetToken ? (
|
|
113
|
+
<div className="center">
|
|
114
|
+
<div className="card narrow">
|
|
115
|
+
<ResetForm token={resetToken} onDone={() => (setResetToken(null), setNotice({ tone: "info", text: "Your password is set. Other sessions were signed out." }))} />
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
96
118
|
) : !principal ? (
|
|
97
|
-
<SignedOut
|
|
119
|
+
<SignedOut
|
|
120
|
+
devUsers={session.devUsers}
|
|
121
|
+
accounts={session.accounts ?? null}
|
|
122
|
+
onDevSignIn={(id) => signInAsDevUser(id).catch(report)}
|
|
123
|
+
onSignIn={signIn}
|
|
124
|
+
onSignUp={signUp}
|
|
125
|
+
/>
|
|
98
126
|
) : (
|
|
99
127
|
<Workspace canWrite={canWrite} canComplete={canComplete} role={principal.roles.join(", ")} onError={report} />
|
|
100
128
|
)}
|
|
101
129
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
130
|
+
{session && !session.environment.production && (
|
|
131
|
+
<footer className="footer">
|
|
132
|
+
Every project, task, and note lives in this app’s single FeltDB instance. Changes made by other people, agents (through AppPort), and services appear here live.
|
|
133
|
+
</footer>
|
|
134
|
+
)}
|
|
105
135
|
</div>
|
|
106
136
|
);
|
|
107
137
|
}
|
|
108
138
|
|
|
109
|
-
function SignedOut(
|
|
139
|
+
function SignedOut(props: {
|
|
140
|
+
devUsers: Array<{ id: string; name: string; roles: string[] }>;
|
|
141
|
+
accounts: { signUp: boolean; email?: boolean; providers?: Array<{ id: string; name: string }> } | null;
|
|
142
|
+
onDevSignIn: (id: string) => void;
|
|
143
|
+
onSignIn: (input: { email: string; password: string }) => Promise<void>;
|
|
144
|
+
onSignUp: (input: { email: string; password: string; name?: string }) => Promise<void>;
|
|
145
|
+
}) {
|
|
110
146
|
return (
|
|
111
147
|
<div className="center">
|
|
112
148
|
<div className="card narrow">
|
|
113
|
-
|
|
114
|
-
{devUsers.length ? (
|
|
149
|
+
{props.devUsers.length ? (
|
|
115
150
|
<>
|
|
151
|
+
<h1>Sign in</h1>
|
|
116
152
|
<p className="muted">Development identities from feltkit/app.ts:</p>
|
|
117
153
|
<div className="stack">
|
|
118
|
-
{devUsers.map((u) => (
|
|
119
|
-
<button key={u.id} className="secondary wide" onClick={() =>
|
|
154
|
+
{props.devUsers.map((u) => (
|
|
155
|
+
<button key={u.id} className="secondary wide" onClick={() => props.onDevSignIn(u.id)}>
|
|
120
156
|
{u.name} <span className="muted">· {u.roles.join(", ")}</span>
|
|
121
157
|
</button>
|
|
122
158
|
))}
|
|
123
159
|
</div>
|
|
124
160
|
</>
|
|
161
|
+
) : props.accounts ? (
|
|
162
|
+
<AccountForm signUpOpen={props.accounts.signUp} email={Boolean(props.accounts.email)} providers={props.accounts.providers ?? []} onSignIn={props.onSignIn} onSignUp={props.onSignUp} />
|
|
125
163
|
) : (
|
|
126
|
-
|
|
164
|
+
<>
|
|
165
|
+
<h1>Sign in</h1>
|
|
166
|
+
<p className="muted">Sign-in isn't available for this app yet.</p>
|
|
167
|
+
</>
|
|
127
168
|
)}
|
|
128
169
|
</div>
|
|
129
170
|
</div>
|
|
130
171
|
);
|
|
131
172
|
}
|
|
132
173
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*/
|
|
137
|
-
|
|
174
|
+
/** Sign in to an existing account, or create one (auth.accounts in feltkit/app.ts). */
|
|
175
|
+
function AccountForm(props: {
|
|
176
|
+
signUpOpen: boolean;
|
|
177
|
+
/** The app sends account e-mail (auth.accounts.email): offer "forgot password". */
|
|
178
|
+
email: boolean;
|
|
179
|
+
providers: Array<{ id: string; name: string }>;
|
|
180
|
+
onSignIn: (input: { email: string; password: string }) => Promise<void>;
|
|
181
|
+
onSignUp: (input: { email: string; password: string; name?: string }) => Promise<void>;
|
|
182
|
+
}) {
|
|
138
183
|
const client = useFeltKit();
|
|
139
|
-
const [
|
|
140
|
-
const
|
|
184
|
+
const [mode, setMode] = useState<"signin" | "signup" | "forgot">("signin");
|
|
185
|
+
const [name, setName] = useState("");
|
|
186
|
+
const [email, setEmail] = useState("");
|
|
187
|
+
const [password, setPassword] = useState("");
|
|
188
|
+
const [error, setError] = useState<string | null>(null);
|
|
189
|
+
const [info, setInfo] = useState<string | null>(null);
|
|
190
|
+
const [busy, setBusy] = useState(false);
|
|
191
|
+
const creating = mode === "signup";
|
|
192
|
+
const forgot = mode === "forgot";
|
|
193
|
+
const go = (next: typeof mode) => (setMode(next), setError(null), setInfo(null));
|
|
194
|
+
const submit = async (e: FormEvent) => {
|
|
141
195
|
e.preventDefault();
|
|
142
|
-
|
|
196
|
+
setError(null);
|
|
197
|
+
setInfo(null);
|
|
198
|
+
setBusy(true);
|
|
199
|
+
try {
|
|
200
|
+
if (forgot) setInfo((await client.requestPasswordReset(email)).message);
|
|
201
|
+
else if (creating) {
|
|
202
|
+
await props.onSignUp({ email, password, ...(name.trim() ? { name: name.trim() } : {}) });
|
|
203
|
+
if (!client.tokens.get()) setInfo("Check your e-mail: open the link we sent to verify your address, then sign in.");
|
|
204
|
+
} else await props.onSignIn({ email, password });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
setError(describe(err));
|
|
207
|
+
} finally {
|
|
208
|
+
setBusy(false);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
return (
|
|
212
|
+
<form className="stack" onSubmit={submit}>
|
|
213
|
+
<h1>{forgot ? "Reset your password" : creating ? "Create your account" : "Sign in"}</h1>
|
|
214
|
+
{!forgot &&
|
|
215
|
+
props.providers.map((p) => (
|
|
216
|
+
<button key={p.id} type="button" className="secondary wide" onClick={() => client.signInWith(p.id)}>
|
|
217
|
+
Continue with {p.name}
|
|
218
|
+
</button>
|
|
219
|
+
))}
|
|
220
|
+
{!forgot && props.providers.length > 0 && <p className="muted small-text">or with your e-mail address</p>}
|
|
221
|
+
{creating && <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" aria-label="Name" autoComplete="name" />}
|
|
222
|
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="E-mail" aria-label="E-mail" autoComplete="email" required />
|
|
223
|
+
{!forgot && (
|
|
224
|
+
<input
|
|
225
|
+
type="password"
|
|
226
|
+
value={password}
|
|
227
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
228
|
+
placeholder={creating ? "Password (12+ characters)" : "Password"}
|
|
229
|
+
aria-label="Password"
|
|
230
|
+
autoComplete={creating ? "new-password" : "current-password"}
|
|
231
|
+
minLength={creating ? 12 : undefined}
|
|
232
|
+
required
|
|
233
|
+
/>
|
|
234
|
+
)}
|
|
235
|
+
{error && <p className="error-text">{error}</p>}
|
|
236
|
+
{info && <p className="muted">{info}</p>}
|
|
237
|
+
<button type="submit" disabled={busy || !email || (!forgot && !password)}>
|
|
238
|
+
{busy ? "One moment…" : forgot ? "Send reset link" : creating ? "Create account" : "Sign in"}
|
|
239
|
+
</button>
|
|
240
|
+
{mode === "signin" && props.email && (
|
|
241
|
+
<p className="muted small-text">
|
|
242
|
+
<button type="button" className="link" onClick={() => go("forgot")}>
|
|
243
|
+
Forgot your password?
|
|
244
|
+
</button>
|
|
245
|
+
</p>
|
|
246
|
+
)}
|
|
247
|
+
{(props.signUpOpen || forgot) && (
|
|
248
|
+
<p className="muted small-text">
|
|
249
|
+
{forgot ? "Remembered it? " : creating ? "Already have an account? " : "New here? "}
|
|
250
|
+
<button type="button" className="link" onClick={() => go(creating || forgot ? "signin" : "signup")}>
|
|
251
|
+
{creating || forgot ? "Sign in" : "Create an account"}
|
|
252
|
+
</button>
|
|
253
|
+
</p>
|
|
254
|
+
)}
|
|
255
|
+
</form>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A password reset link opened: choose a new password. */
|
|
260
|
+
function ResetForm({ token, onDone }: { token: string; onDone: () => void }) {
|
|
261
|
+
const client = useFeltKit();
|
|
262
|
+
const [password, setPassword] = useState("");
|
|
263
|
+
const [error, setError] = useState<string | null>(null);
|
|
264
|
+
const [busy, setBusy] = useState(false);
|
|
265
|
+
const submit = async (e: FormEvent) => {
|
|
266
|
+
e.preventDefault();
|
|
267
|
+
setError(null);
|
|
268
|
+
setBusy(true);
|
|
269
|
+
try {
|
|
270
|
+
await client.resetPassword(token, password);
|
|
271
|
+
onDone();
|
|
272
|
+
} catch (err) {
|
|
273
|
+
setError(describe(err));
|
|
274
|
+
} finally {
|
|
275
|
+
setBusy(false);
|
|
276
|
+
}
|
|
143
277
|
};
|
|
144
278
|
return (
|
|
145
279
|
<form className="stack" onSubmit={submit}>
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<p className="muted">If AppBoundry runs this app (from its <code>.app</code> artifact), its operator creates your user and issues your session instead:</p>
|
|
152
|
-
<code className="block">appboundry admin sessions issue --user you --state <installation></code>
|
|
153
|
-
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="Session token (fks_…)" aria-label="Session token" autoComplete="off" />
|
|
154
|
-
<button type="submit" disabled={!token.trim()}>
|
|
155
|
-
Sign in
|
|
280
|
+
<h1>Choose a new password</h1>
|
|
281
|
+
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="New password (12+ characters)" aria-label="New password" autoComplete="new-password" minLength={12} required />
|
|
282
|
+
{error && <p className="error-text">{error}</p>}
|
|
283
|
+
<button type="submit" disabled={busy || !password}>
|
|
284
|
+
{busy ? "One moment…" : "Set password and sign in"}
|
|
156
285
|
</button>
|
|
157
|
-
<p className="muted small-text">To sign people in with your own login, add auth.authenticate in feltkit/app.ts.</p>
|
|
158
286
|
</form>
|
|
159
287
|
);
|
|
160
288
|
}
|
package/template/src/styles.css
CHANGED
|
@@ -127,3 +127,8 @@ code.block { display: block; padding: 8px 10px; border-radius: 6px; background:
|
|
|
127
127
|
.identity, .identity select { max-width: 100%; min-width: 0; }
|
|
128
128
|
.task-detail { padding-left: 16px; }
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
/* Account sign-in (auth.accounts) */
|
|
132
|
+
button.link { background: none; border: 0; padding: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer; }
|
|
133
|
+
button.ghost.signout { font-size: 14px; }
|
|
134
|
+
.card form.stack input { padding: 9px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"deploy:fly": "node scripts/deploy-fly.mjs"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@feltdb/feltkit": "0.3.
|
|
23
|
+
"@feltdb/feltkit": "0.3.10"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@feltdb/core": "0.
|
|
26
|
+
"@feltdb/core": ">=0.17.1 <1.0.0",
|
|
27
27
|
"@types/node": "22.19.1",
|
|
28
28
|
"typescript": "5.9.3"
|
|
29
29
|
}
|