@commonpub/layer 0.21.0 → 0.21.2
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 +7 -7
- package/pages/auth/login.vue +122 -6
- package/server/plugins/auto-admin.ts +33 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -50,16 +50,16 @@
|
|
|
50
50
|
"vue": "^3.4.0",
|
|
51
51
|
"vue-router": "^4.3.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@commonpub/
|
|
54
|
-
"@commonpub/docs": "0.6.2",
|
|
53
|
+
"@commonpub/config": "0.12.0",
|
|
55
54
|
"@commonpub/editor": "0.7.9",
|
|
56
|
-
"@commonpub/learning": "0.5.2",
|
|
57
55
|
"@commonpub/explainer": "0.7.12",
|
|
58
|
-
"@commonpub/
|
|
59
|
-
"@commonpub/config": "0.12.0",
|
|
56
|
+
"@commonpub/learning": "0.5.2",
|
|
60
57
|
"@commonpub/server": "2.51.0",
|
|
58
|
+
"@commonpub/auth": "0.6.0",
|
|
61
59
|
"@commonpub/ui": "0.8.5",
|
|
62
|
-
"@commonpub/protocol": "0.9.9"
|
|
60
|
+
"@commonpub/protocol": "0.9.9",
|
|
61
|
+
"@commonpub/schema": "0.16.0",
|
|
62
|
+
"@commonpub/docs": "0.6.2"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/auth/login.vue
CHANGED
|
@@ -7,7 +7,7 @@ useSeoMeta({
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
const { signIn, refreshSession } = useAuth();
|
|
10
|
-
const { federation } = useFeatures();
|
|
10
|
+
const { federation, identity: identityFeatures } = useFeatures();
|
|
11
11
|
const route = useRoute();
|
|
12
12
|
|
|
13
13
|
const identity = ref('');
|
|
@@ -15,11 +15,23 @@ const password = ref('');
|
|
|
15
15
|
const error = ref('');
|
|
16
16
|
const loading = ref(false);
|
|
17
17
|
|
|
18
|
-
//
|
|
18
|
+
// CommonPub-to-CommonPub (v1 SSO) state — `features.federation` gate
|
|
19
19
|
const federatedDomain = ref('');
|
|
20
20
|
const federatedLoading = ref(false);
|
|
21
21
|
const federatedError = ref('');
|
|
22
22
|
|
|
23
|
+
// Mastodon-API login state (Phase 2b) — `features.identity.signInWithRemote` gate.
|
|
24
|
+
// Accepts `@user@host`, `user@host`, or bare `host` (no leading @).
|
|
25
|
+
// On submit, parses out the host and redirects to /api/auth/mastodon/start.
|
|
26
|
+
const mastodonHandle = ref('');
|
|
27
|
+
const mastodonError = ref('');
|
|
28
|
+
|
|
29
|
+
// Surface server-side errors redirected back from the callback (?mastodon_error=...)
|
|
30
|
+
onMounted(() => {
|
|
31
|
+
const queryErr = route.query.mastodon_error;
|
|
32
|
+
if (typeof queryErr === 'string' && queryErr) mastodonError.value = queryErr;
|
|
33
|
+
});
|
|
34
|
+
|
|
23
35
|
// Federated callback context — present when redirected back from OAuth callback.
|
|
24
36
|
// Only an opaque linkToken is passed; the verified identity stays server-side.
|
|
25
37
|
const federatedLinkToken = computed(() => {
|
|
@@ -99,6 +111,53 @@ async function handleFederatedLogin(): Promise<void> {
|
|
|
99
111
|
federatedLoading.value = false;
|
|
100
112
|
}
|
|
101
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse a handle string into a host. Accepts:
|
|
117
|
+
* - `@user@mastodon.social` → mastodon.social
|
|
118
|
+
* - `user@mastodon.social` → mastodon.social
|
|
119
|
+
* - `acct:user@mastodon.social` → mastodon.social
|
|
120
|
+
* - bare `mastodon.social` → mastodon.social
|
|
121
|
+
* Returns null for anything that doesn't look like a host (e.g., bare username, email).
|
|
122
|
+
*/
|
|
123
|
+
function extractHost(input: string): string | null {
|
|
124
|
+
const trimmed = input.trim().replace(/^acct:/i, '');
|
|
125
|
+
if (!trimmed) return null;
|
|
126
|
+
// user@host shape (with or without leading @)
|
|
127
|
+
const stripped = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
|
|
128
|
+
const atIdx = stripped.indexOf('@');
|
|
129
|
+
if (atIdx > 0 && atIdx === stripped.lastIndexOf('@')) {
|
|
130
|
+
const host = stripped.slice(atIdx + 1).toLowerCase();
|
|
131
|
+
return isHostShape(host) ? host : null;
|
|
132
|
+
}
|
|
133
|
+
// Bare host (no @ anywhere, must contain a dot)
|
|
134
|
+
if (atIdx === -1 && stripped.includes('.')) {
|
|
135
|
+
return isHostShape(stripped.toLowerCase());
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isHostShape(host: string): string | null {
|
|
141
|
+
if (!host || host.length > 253) return null;
|
|
142
|
+
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d+)?$/i.test(host)) return null;
|
|
143
|
+
if (!host.includes('.')) return null;
|
|
144
|
+
return host;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function handleMastodonLogin(): void {
|
|
148
|
+
mastodonError.value = '';
|
|
149
|
+
const host = extractHost(mastodonHandle.value);
|
|
150
|
+
if (!host) {
|
|
151
|
+
mastodonError.value = 'Enter a handle like @user@mastodon.social or just mastodon.social';
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// The start route is a GET that redirects to the remote's /oauth/authorize.
|
|
155
|
+
// Use window.location.href so the browser actually navigates (and cookies
|
|
156
|
+
// for the eventual callback land correctly).
|
|
157
|
+
const params = new URLSearchParams({ host });
|
|
158
|
+
if (redirectTo.value && redirectTo.value !== '/') params.set('returnTo', redirectTo.value);
|
|
159
|
+
window.location.href = `/api/auth/mastodon/start?${params.toString()}`;
|
|
160
|
+
}
|
|
102
161
|
</script>
|
|
103
162
|
|
|
104
163
|
<template>
|
|
@@ -154,16 +213,16 @@ async function handleFederatedLogin(): Promise<void> {
|
|
|
154
213
|
<NuxtLink to="/auth/forgot-password" class="forgot-link">Forgot your password?</NuxtLink>
|
|
155
214
|
</form>
|
|
156
215
|
|
|
157
|
-
<!--
|
|
216
|
+
<!-- v1 SSO federation section — gated by features.federation; CommonPub-only via trustedInstances -->
|
|
158
217
|
<div v-if="federation && !federatedLinkToken" class="cpub-federated-section">
|
|
159
218
|
<div class="cpub-federated-divider">
|
|
160
219
|
<span class="cpub-federated-divider-text">or</span>
|
|
161
220
|
</div>
|
|
162
221
|
|
|
163
|
-
<form class="cpub-federated-form" @submit.prevent="handleFederatedLogin" aria-label="Sign in with another instance">
|
|
222
|
+
<form class="cpub-federated-form" @submit.prevent="handleFederatedLogin" aria-label="Sign in with another CommonPub instance">
|
|
164
223
|
<div v-if="federatedError" class="form-error" role="alert">{{ federatedError }}</div>
|
|
165
224
|
|
|
166
|
-
<label for="federated-domain" class="field-label">Sign in with another instance</label>
|
|
225
|
+
<label for="federated-domain" class="field-label">Sign in with another CommonPub instance</label>
|
|
167
226
|
<div class="cpub-federated-input-group">
|
|
168
227
|
<input
|
|
169
228
|
id="federated-domain"
|
|
@@ -174,13 +233,54 @@ async function handleFederatedLogin(): Promise<void> {
|
|
|
174
233
|
required
|
|
175
234
|
autocomplete="off"
|
|
176
235
|
/>
|
|
177
|
-
<button type="submit" class="cpub-federated-btn" :disabled="federatedLoading" aria-label="Sign in with remote instance">
|
|
236
|
+
<button type="submit" class="cpub-federated-btn" :disabled="federatedLoading" aria-label="Sign in with remote CommonPub instance">
|
|
178
237
|
{{ federatedLoading ? 'Connecting...' : 'Go' }}
|
|
179
238
|
</button>
|
|
180
239
|
</div>
|
|
181
240
|
</form>
|
|
182
241
|
</div>
|
|
183
242
|
|
|
243
|
+
<!--
|
|
244
|
+
Mastodon-API login section (Phase 2b) — gated by features.identity.signInWithRemote.
|
|
245
|
+
Works with any Mastodon-API-compatible host: Mastodon, Pleroma, Akkoma, GoToSocial,
|
|
246
|
+
Firefish, and other CommonPub instances. On submit, parses the input to extract a
|
|
247
|
+
host and navigates to /api/auth/mastodon/start, which registers our OAuth client at
|
|
248
|
+
the remote and redirects the user to their authorize page.
|
|
249
|
+
-->
|
|
250
|
+
<div v-if="identityFeatures.signInWithRemote && !federatedLinkToken" class="cpub-federated-section">
|
|
251
|
+
<div class="cpub-federated-divider">
|
|
252
|
+
<span class="cpub-federated-divider-text">or</span>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<form class="cpub-federated-form" @submit.prevent="handleMastodonLogin" aria-label="Sign in with Mastodon or any Fediverse instance">
|
|
256
|
+
<div v-if="mastodonError" class="form-error" role="alert">{{ mastodonError }}</div>
|
|
257
|
+
|
|
258
|
+
<label for="mastodon-handle" class="field-label">
|
|
259
|
+
Sign in with Mastodon
|
|
260
|
+
<span class="field-label-note">— or Pleroma, GoToSocial, Akkoma, Firefish</span>
|
|
261
|
+
</label>
|
|
262
|
+
<div class="cpub-federated-input-group">
|
|
263
|
+
<input
|
|
264
|
+
id="mastodon-handle"
|
|
265
|
+
v-model="mastodonHandle"
|
|
266
|
+
type="text"
|
|
267
|
+
class="field-input"
|
|
268
|
+
placeholder="@user@mastodon.social or mastodon.social"
|
|
269
|
+
autocomplete="off"
|
|
270
|
+
inputmode="email"
|
|
271
|
+
spellcheck="false"
|
|
272
|
+
autocapitalize="off"
|
|
273
|
+
/>
|
|
274
|
+
<button type="submit" class="cpub-federated-btn" aria-label="Sign in with Fediverse instance">
|
|
275
|
+
Sign in
|
|
276
|
+
</button>
|
|
277
|
+
</div>
|
|
278
|
+
<p class="cpub-federated-hint">
|
|
279
|
+
You'll be redirected to your home instance to confirm. No new password needed.
|
|
280
|
+
</p>
|
|
281
|
+
</form>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
184
284
|
<p class="login-footer">
|
|
185
285
|
Don't have an account?
|
|
186
286
|
<NuxtLink to="/auth/register">Register</NuxtLink>
|
|
@@ -394,4 +494,20 @@ async function handleFederatedLogin(): Promise<void> {
|
|
|
394
494
|
opacity: 0.7;
|
|
395
495
|
cursor: not-allowed;
|
|
396
496
|
}
|
|
497
|
+
|
|
498
|
+
.field-label-note {
|
|
499
|
+
font-weight: 400;
|
|
500
|
+
font-family: var(--font-sans);
|
|
501
|
+
text-transform: none;
|
|
502
|
+
letter-spacing: 0;
|
|
503
|
+
color: var(--text-faint);
|
|
504
|
+
font-size: 11px;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.cpub-federated-hint {
|
|
508
|
+
font-size: 11px;
|
|
509
|
+
color: var(--text-faint);
|
|
510
|
+
margin: 4px 0 0 0;
|
|
511
|
+
line-height: 1.4;
|
|
512
|
+
}
|
|
397
513
|
</style>
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Admin bootstrap plugin.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* In production: promotes ADMIN_BOOTSTRAP_USER (env var) if no admin exists.
|
|
4
|
+
* Runs once when no admin exists yet:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* - Development (NODE_ENV !== 'production'): promotes the first
|
|
7
|
+
* registered user to admin. Zero config.
|
|
8
|
+
* - Production, ADMIN_BOOTSTRAP_USER set: promotes that username.
|
|
9
|
+
* The canonical "set the admin directly" path.
|
|
10
|
+
* - Production, ADMIN_BOOTSTRAP_FIRST_USER truthy (1/true/yes):
|
|
11
|
+
* promotes the first registered user — the frictionless
|
|
12
|
+
* one-click-deploy path (deploy → register → you're admin).
|
|
13
|
+
* - Production, neither set: does nothing (safe default — a public
|
|
14
|
+
* instance shouldn't hand admin to a random first signup unless
|
|
15
|
+
* the operator explicitly opted in).
|
|
16
|
+
*
|
|
17
|
+
* Idempotent: the whole block early-returns once any admin exists, so
|
|
18
|
+
* this is safe to leave enabled forever and ships harmlessly to
|
|
19
|
+
* instances that already have admins (no behavior change there).
|
|
10
20
|
*/
|
|
11
21
|
import { users } from '@commonpub/schema';
|
|
12
22
|
import { eq, asc, count } from 'drizzle-orm';
|
|
13
23
|
|
|
24
|
+
/** Truthy env check — accepts 1/true/yes (case-insensitive). */
|
|
25
|
+
function envTruthy(value: string | undefined): boolean {
|
|
26
|
+
return /^(1|true|yes)$/i.test(value ?? '');
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export default defineNitroPlugin((nitro) => {
|
|
15
30
|
// Run after a short delay so the DB pool is ready
|
|
16
31
|
setTimeout(async () => {
|
|
@@ -27,14 +42,21 @@ export default defineNitroPlugin((nitro) => {
|
|
|
27
42
|
|
|
28
43
|
// No admins exist — bootstrap one
|
|
29
44
|
|
|
30
|
-
// Production: promote specific user from env var
|
|
31
45
|
const bootstrapUsername = process.env.ADMIN_BOOTSTRAP_USER;
|
|
32
|
-
|
|
46
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
47
|
+
// First-user promotion: default in dev, opt-in in prod via
|
|
48
|
+
// ADMIN_BOOTSTRAP_FIRST_USER (the one-click-deploy path).
|
|
49
|
+
const allowFirstUser = !isProd || envTruthy(process.env.ADMIN_BOOTSTRAP_FIRST_USER);
|
|
50
|
+
|
|
51
|
+
// In production, do nothing unless the operator either named a
|
|
52
|
+
// user OR explicitly opted into first-user promotion. Preserves
|
|
53
|
+
// the original safe default for instances that set neither.
|
|
54
|
+
if (isProd && !bootstrapUsername && !allowFirstUser) return;
|
|
33
55
|
|
|
34
56
|
let targetUser: { id: string; username: string } | undefined;
|
|
35
57
|
|
|
36
58
|
if (bootstrapUsername) {
|
|
37
|
-
// Promote the specified user
|
|
59
|
+
// Promote the specified user (canonical "set admin directly").
|
|
38
60
|
const [found] = await db
|
|
39
61
|
.select({ id: users.id, username: users.username })
|
|
40
62
|
.from(users)
|
|
@@ -44,8 +66,9 @@ export default defineNitroPlugin((nitro) => {
|
|
|
44
66
|
if (!targetUser) {
|
|
45
67
|
console.warn(`[auto-admin] ADMIN_BOOTSTRAP_USER="${bootstrapUsername}" not found`);
|
|
46
68
|
}
|
|
47
|
-
} else {
|
|
48
|
-
//
|
|
69
|
+
} else if (allowFirstUser) {
|
|
70
|
+
// Promote the first registered user (dev always; prod only
|
|
71
|
+
// when ADMIN_BOOTSTRAP_FIRST_USER opted in).
|
|
49
72
|
const [firstUser] = await db
|
|
50
73
|
.select({ id: users.id, username: users.username })
|
|
51
74
|
.from(users)
|