NE Hub: Seafile, OnlyOffice, Plex, calendar and mail in one PWA
build-container / build (push) Canceled after 4m16s

- Mobile-first PWA (phone tab bar with More menu, iPad/desktop rail)
- Passkey (WebAuthn) sign-in gates the whole app; first passkey via setup code
- Files: Seafile browse/upload/new doc/rename/move/share link/preview/delete
- In-app OnlyOffice editing with JWT-signed configs and save-back to Seafile
- Plex music player via plex.tv sign-in and a server-side proxy
- Calendar agenda from private ICS links (recurrence + time zones)
- Search, Home dashboard, webmail deep links, audiobooks frame
- Dockerfile, compose, and a Gitea Actions workflow that builds the image

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-20 13:22:03 -04:00
commit 55e0c39171
22 files changed
+2341

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
// Shared passkey helpers (sign-in page + Settings).
const b64u = {
enc: buf => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
dec: s => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - s.length % 4) % 4)), c => c.charCodeAt(0)),
};
async function authPost(path, body, method = 'POST') {
const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || 'Request failed (' + r.status + ')');
return j;
}
async function passkeyRegister(setupCode, name) {
const { cid, options } = await authPost('/api/auth/register/options', { setupCode });
options.challenge = b64u.dec(options.challenge);
options.user.id = b64u.dec(options.user.id);
(options.excludeCredentials || []).forEach(c => { c.id = b64u.dec(c.id); });
const cred = await navigator.credentials.create({ publicKey: options });
await authPost('/api/auth/register/verify', { cid, name, response: {
id: cred.id, rawId: b64u.enc(cred.rawId), type: cred.type,
response: { clientDataJSON: b64u.enc(cred.response.clientDataJSON), attestationObject: b64u.enc(cred.response.attestationObject),
transports: cred.response.getTransports ? cred.response.getTransports() : [] },
clientExtensionResults: cred.getClientExtensionResults(), authenticatorAttachment: cred.authenticatorAttachment,
} });
}
async function passkeyLogin() {
const { cid, options } = await authPost('/api/auth/login/options');
options.challenge = b64u.dec(options.challenge);
(options.allowCredentials || []).forEach(c => { c.id = b64u.dec(c.id); });
const a = await navigator.credentials.get({ publicKey: options });
await authPost('/api/auth/login/verify', { cid, response: {
id: a.id, rawId: b64u.enc(a.rawId), type: a.type,
response: { clientDataJSON: b64u.enc(a.response.clientDataJSON), authenticatorData: b64u.enc(a.response.authenticatorData),
signature: b64u.enc(a.response.signature), userHandle: a.response.userHandle ? b64u.enc(a.response.userHandle) : undefined },
clientExtensionResults: a.getClientExtensionResults(),
} });
}