add server-side global auth for private selfhosted instance
This commit is contained in:
parent
62b83ea9e9
commit
5ffb14560b
11 changed files with 674 additions and 17 deletions
11
.env.example
11
.env.example
|
|
@ -5,6 +5,17 @@
|
|||
MONOCHROME_PORT=3000
|
||||
MONOCHROME_DEV_PORT=5173
|
||||
|
||||
# --- Auth Gate (server-side authentication) ---
|
||||
# Set AUTH_ENABLED=false to disable the auth gate entirely (no login required)
|
||||
AUTH_ENABLED=true
|
||||
AUTH_SECRET=change-me-to-a-random-string
|
||||
FIREBASE_PROJECT_ID=monochrome-database
|
||||
# Optional: override the Firebase config for the login page (JSON string)
|
||||
# FIREBASE_CONFIG={"apiKey":"...","authDomain":"...","projectId":"...","storageBucket":"...","messagingSenderId":"...","appId":"..."}
|
||||
# Optional: set PocketBase URL (hides the field in settings when set)
|
||||
# POCKETBASE_URL=https://monodb.samidy.com
|
||||
# SESSION_MAX_AGE=604800000 # 7 days in ms (default)
|
||||
|
||||
# --- PocketBase (only used with --profile pocketbase) ---
|
||||
POCKETBASE_PORT=8090
|
||||
PB_ADMIN_EMAIL=admin@example.com
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ WORKDIR /app
|
|||
RUN apk add --no-cache wget
|
||||
|
||||
# Copy package files first for caching
|
||||
COPY package.json ./
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@ services:
|
|||
container_name: monochrome
|
||||
ports:
|
||||
- '${MONOCHROME_PORT:-3000}:4173'
|
||||
environment:
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-true}
|
||||
AUTH_SECRET: ${AUTH_SECRET:-}
|
||||
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-monochrome-database}
|
||||
FIREBASE_CONFIG: ${FIREBASE_CONFIG:-}
|
||||
POCKETBASE_URL: ${POCKETBASE_URL:-}
|
||||
SESSION_MAX_AGE: ${SESSION_MAX_AGE:-604800000}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- monochrome-network
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/']
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:4173/health']
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -103,7 +103,14 @@ export class AuthManager {
|
|||
|
||||
try {
|
||||
await firebaseSignOut(auth);
|
||||
// The onAuthStateChanged listener will handle the rest
|
||||
if (window.__AUTH_GATE__) {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch (_) {
|
||||
// Server endpoint may not exist in dev mode
|
||||
}
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
throw error;
|
||||
|
|
@ -119,6 +126,39 @@ export class AuthManager {
|
|||
|
||||
if (!connectBtn) return; // UI might not be rendered yet
|
||||
|
||||
// Auth gate active: strip down to status + sign out only
|
||||
if (window.__AUTH_GATE__) {
|
||||
connectBtn.textContent = 'Sign Out';
|
||||
connectBtn.classList.add('danger');
|
||||
connectBtn.onclick = () => this.signOut();
|
||||
if (clearDataBtn) clearDataBtn.style.display = 'none';
|
||||
if (emailContainer) emailContainer.style.display = 'none';
|
||||
if (emailToggleBtn) emailToggleBtn.style.display = 'none';
|
||||
if (statusText) statusText.textContent = user ? `Signed in as ${user.email}` : 'Signed in';
|
||||
|
||||
// Account page: clean up unnecessary text
|
||||
const accountPage = document.getElementById('page-account');
|
||||
if (accountPage) {
|
||||
const title = accountPage.querySelector('.section-title');
|
||||
if (title) title.textContent = 'Account';
|
||||
// Hide description + privacy paragraphs, keep only status
|
||||
accountPage.querySelectorAll('.account-content > p, .account-content > div').forEach((el) => {
|
||||
if (el.id !== 'firebase-status' && el.id !== 'auth-buttons-container') {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Settings page: hide custom DB/Auth config
|
||||
const customDbBtn = document.getElementById('custom-db-btn');
|
||||
if (customDbBtn) {
|
||||
const settingItem = customDbBtn.closest('.setting-item');
|
||||
if (settingItem) settingItem.style.display = 'none';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
connectBtn.textContent = 'Sign Out';
|
||||
connectBtn.classList.add('danger');
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ function getStoredConfig() {
|
|||
}
|
||||
|
||||
// Attempt to initialize on load
|
||||
// Priority: server-injected env (auth gate) > localStorage > default
|
||||
const storedConfig = getStoredConfig();
|
||||
const config = storedConfig || DEFAULT_CONFIG;
|
||||
const config = window.__FIREBASE_CONFIG__ || storedConfig || DEFAULT_CONFIG;
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1606,19 +1606,34 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
|||
const customDbCancelBtn = document.getElementById('custom-db-cancel');
|
||||
|
||||
if (customDbBtn && customDbModal) {
|
||||
const fbFromEnv = !!window.__FIREBASE_CONFIG__;
|
||||
const pbFromEnv = !!window.__POCKETBASE_URL__;
|
||||
|
||||
// Hide entire setting if both are server-configured
|
||||
if (fbFromEnv && pbFromEnv) {
|
||||
const settingItem = customDbBtn.closest('.setting-item');
|
||||
if (settingItem) settingItem.style.display = 'none';
|
||||
}
|
||||
|
||||
// Hide individual fields in the modal
|
||||
if (pbFromEnv && customPbUrlInput) customPbUrlInput.closest('div[style]').style.display = 'none';
|
||||
if (fbFromEnv && customFirebaseConfigInput) customFirebaseConfigInput.closest('div[style]').style.display = 'none';
|
||||
|
||||
customDbBtn.addEventListener('click', () => {
|
||||
const pbUrl = localStorage.getItem('monochrome-pocketbase-url') || '';
|
||||
const fbConfig = localStorage.getItem('monochrome-firebase-config');
|
||||
|
||||
customPbUrlInput.value = pbUrl;
|
||||
if (fbConfig) {
|
||||
try {
|
||||
customFirebaseConfigInput.value = JSON.stringify(JSON.parse(fbConfig), null, 2);
|
||||
} catch {
|
||||
customFirebaseConfigInput.value = fbConfig;
|
||||
if (!pbFromEnv) customPbUrlInput.value = pbUrl;
|
||||
if (!fbFromEnv) {
|
||||
if (fbConfig) {
|
||||
try {
|
||||
customFirebaseConfigInput.value = JSON.stringify(JSON.parse(fbConfig), null, 2);
|
||||
} catch {
|
||||
customFirebaseConfigInput.value = fbConfig;
|
||||
}
|
||||
} else {
|
||||
customFirebaseConfigInput.value = '';
|
||||
}
|
||||
} else {
|
||||
customFirebaseConfigInput.value = '';
|
||||
}
|
||||
|
||||
customDbModal.classList.add('active');
|
||||
|
|
|
|||
95
package-lock.json
generated
95
package-lock.json
generated
|
|
@ -11,7 +11,9 @@
|
|||
"dependencies": {
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"cookie-session": "^2.1.0",
|
||||
"dashjs": "^5.1.1",
|
||||
"jose": "^6.0.11",
|
||||
"pocketbase": "^0.26.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -2467,9 +2469,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3751,6 +3753,43 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-session": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz",
|
||||
"integrity": "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookies": "0.9.1",
|
||||
"debug": "3.2.7",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-session/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cookies": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
|
||||
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"keygrip": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
|
|
@ -4011,6 +4050,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
|
|
@ -5970,6 +6018,15 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -6074,6 +6131,18 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keygrip": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||
"integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tsscmp": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
|
@ -6329,7 +6398,6 @@
|
|||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
|
|
@ -6448,6 +6516,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -7139,7 +7216,6 @@
|
|||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -8176,6 +8252,15 @@
|
|||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsscmp": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
|
||||
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.x"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "vite preview",
|
||||
"lint:js": "eslint .",
|
||||
"lint:css": "stylelint \"**/*.css\"",
|
||||
"lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"",
|
||||
|
|
@ -43,6 +44,8 @@
|
|||
"source-map": "^0.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie-session": "^2.1.0",
|
||||
"jose": "^6.0.11",
|
||||
"butterchurn": "^2.6.7",
|
||||
"butterchurn-presets": "^2.4.7",
|
||||
"dashjs": "^5.1.1",
|
||||
|
|
|
|||
290
public/login.html
Normal file
290
public/login.html
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Monochrome — Login</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.logo svg { width: 40px; height: 40px; }
|
||||
|
||||
.logo span {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
background: #151515;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.925rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn:hover { background: #1e1e1e; border-color: #3a3a3a; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary {
|
||||
background: #fff;
|
||||
color: #0a0a0a;
|
||||
border-color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: #e0e0e0; border-color: #e0e0e0; }
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
color: #555;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.divider::before, .divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
background: #151515;
|
||||
color: #e0e0e0;
|
||||
font-size: 0.925rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-group input:focus { border-color: #555; }
|
||||
|
||||
.error-msg {
|
||||
background: #1a0000;
|
||||
border: 1px solid #3a1111;
|
||||
color: #ff6b6b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.google-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="14.75 14.75 70.5 70.5">
|
||||
<g fill="white">
|
||||
<path d="M38.25 14.75H85.25V61.75H61.75V38.25H38.25ZM14.75 38.25H38.25V61.75H61.75V85.25H14.75Z" />
|
||||
</g>
|
||||
</svg>
|
||||
<span>Monochrome</span>
|
||||
</div>
|
||||
|
||||
<div id="error" class="error-msg"></div>
|
||||
|
||||
<button id="google-btn" class="btn" onclick="googleSignIn()">
|
||||
<svg class="google-icon" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
|
||||
<div class="divider">or</div>
|
||||
|
||||
<form id="email-form" onsubmit="emailAuth(event)">
|
||||
<div class="form-group">
|
||||
<input type="email" id="email" placeholder="Email" required autocomplete="email" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="password" placeholder="Password" required autocomplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" id="email-btn" class="btn btn-primary">
|
||||
<span id="email-btn-text">Sign In</span>
|
||||
<span id="email-btn-spinner" class="spinner"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js';
|
||||
import {
|
||||
getAuth,
|
||||
GoogleAuthProvider,
|
||||
signInWithPopup,
|
||||
signInWithEmailAndPassword,
|
||||
} from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js';
|
||||
|
||||
const STORAGE_KEY = 'monochrome-firebase-config';
|
||||
const DEFAULT_CONFIG = {
|
||||
apiKey: 'AIzaSyDPU-unAjuLtQJt4IkGS5faG50UCF7lYyA',
|
||||
authDomain: 'monochrome-database.firebaseapp.com',
|
||||
projectId: 'monochrome-database',
|
||||
storageBucket: 'monochrome-database.firebasestorage.app',
|
||||
messagingSenderId: '895657412760',
|
||||
appId: '1:895657412760:web:e81c5044c7f4e9b799e8ed',
|
||||
};
|
||||
|
||||
// Check for setup_firebase hash import
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#setup_firebase=')) {
|
||||
try {
|
||||
const encoded = hash.split('#setup_firebase=')[1];
|
||||
const config = JSON.parse(atob(encoded));
|
||||
if (config.apiKey && config.authDomain) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
window.history.replaceState(null, null, '/login');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to import Firebase config from hash:', e);
|
||||
}
|
||||
}
|
||||
|
||||
let storedConfig = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) storedConfig = JSON.parse(raw);
|
||||
} catch (_) {}
|
||||
|
||||
// Priority: server-injected env > localStorage > hash-imported > default
|
||||
const firebaseConfig = window.__FIREBASE_CONFIG__ || storedConfig || DEFAULT_CONFIG;
|
||||
// Sync to localStorage so the app's config.js picks up the same project
|
||||
if (window.__FIREBASE_CONFIG__ && !storedConfig) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(firebaseConfig));
|
||||
}
|
||||
const fbApp = initializeApp(firebaseConfig);
|
||||
const auth = getAuth(fbApp);
|
||||
const provider = new GoogleAuthProvider();
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('error');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('error').style.display = 'none';
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
document.getElementById('google-btn').disabled = loading;
|
||||
document.getElementById('email-btn').disabled = loading;
|
||||
document.getElementById('email-btn-text').style.display = loading ? 'none' : 'inline';
|
||||
document.getElementById('email-btn-spinner').style.display = loading ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
async function sendTokenToServer(user) {
|
||||
const token = await user.getIdToken();
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Server authentication failed');
|
||||
}
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
window.googleSignIn = async function () {
|
||||
hideError();
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await signInWithPopup(auth, provider);
|
||||
await sendTokenToServer(result.user);
|
||||
} catch (err) {
|
||||
if (err.code !== 'auth/popup-closed-by-user') {
|
||||
showError(err.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.emailAuth = async function (e) {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
setLoading(true);
|
||||
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const result = await signInWithEmailAndPassword(auth, email, password);
|
||||
await sendTokenToServer(result.user);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
203
vite-plugin-auth-gate.js
Normal file
203
vite-plugin-auth-gate.js
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { loadEnv } from 'vite';
|
||||
import cookieSession from 'cookie-session';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
|
||||
function parseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => (body += chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch {
|
||||
reject(new Error('Invalid JSON'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export default function authGatePlugin() {
|
||||
let env = {};
|
||||
|
||||
return {
|
||||
name: 'auth-gate',
|
||||
|
||||
config(_, { mode }) {
|
||||
env = loadEnv(mode, process.cwd(), '');
|
||||
},
|
||||
|
||||
configurePreviewServer(server) {
|
||||
const AUTH_ENABLED = (env.AUTH_ENABLED ?? 'true') !== 'false';
|
||||
const FIREBASE_CONFIG = env.FIREBASE_CONFIG;
|
||||
const POCKETBASE_URL = env.POCKETBASE_URL;
|
||||
|
||||
// Parse Firebase config once (used for injection + auth verification)
|
||||
let parsedFirebaseConfig = null;
|
||||
let PROJECT_ID = env.FIREBASE_PROJECT_ID || 'monochrome-database';
|
||||
if (FIREBASE_CONFIG) {
|
||||
try {
|
||||
parsedFirebaseConfig = JSON.parse(FIREBASE_CONFIG);
|
||||
if (parsedFirebaseConfig.projectId) PROJECT_ID = parsedFirebaseConfig.projectId;
|
||||
} catch (e) {
|
||||
console.error('Invalid FIREBASE_CONFIG JSON:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build injection script (always, for both auth gate and env config) ---
|
||||
|
||||
const flags = [];
|
||||
if (AUTH_ENABLED) flags.push('window.__AUTH_GATE__=true');
|
||||
if (parsedFirebaseConfig) flags.push(`window.__FIREBASE_CONFIG__=${JSON.stringify(parsedFirebaseConfig)}`);
|
||||
if (POCKETBASE_URL) flags.push(`window.__POCKETBASE_URL__=${JSON.stringify(POCKETBASE_URL)}`);
|
||||
const configScript = flags.length > 0 ? `<script>${flags.join(';')};</script>` : null;
|
||||
|
||||
// --- Pre-build injected HTML pages ---
|
||||
|
||||
const distDir = join(process.cwd(), 'dist');
|
||||
|
||||
let indexHtml = null;
|
||||
const indexPath = join(distDir, 'index.html');
|
||||
if (configScript && existsSync(indexPath)) {
|
||||
indexHtml = readFileSync(indexPath, 'utf-8');
|
||||
indexHtml = indexHtml.replace('</head>', `${configScript}\n</head>`);
|
||||
}
|
||||
|
||||
let loginHtml = null;
|
||||
if (AUTH_ENABLED) {
|
||||
const loginPath = join(distDir, 'login.html');
|
||||
if (existsSync(loginPath)) {
|
||||
loginHtml = readFileSync(loginPath, 'utf-8');
|
||||
if (configScript) loginHtml = loginHtml.replace('</head>', `${configScript}\n</head>`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- /health (always available) ---
|
||||
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url.split('?')[0] === '/health') {
|
||||
res.end('OK');
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// --- Auth gate (only when enabled) ---
|
||||
|
||||
if (AUTH_ENABLED) {
|
||||
const AUTH_SECRET = env.AUTH_SECRET;
|
||||
const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (!AUTH_SECRET) {
|
||||
console.error('AUTH_SECRET is required when AUTH_ENABLED=true');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Auth gate enabled (Firebase project: ${PROJECT_ID})`);
|
||||
|
||||
const JWKS = createRemoteJWKSet(
|
||||
new URL(
|
||||
'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com',
|
||||
),
|
||||
);
|
||||
|
||||
server.middlewares.use(
|
||||
cookieSession({
|
||||
name: 'mono_session',
|
||||
keys: [AUTH_SECRET],
|
||||
maxAge: SESSION_MAX_AGE,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
}),
|
||||
);
|
||||
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
const url = req.url.split('?')[0];
|
||||
|
||||
if (url === '/login' || url === '/login.html') {
|
||||
if (loginHtml) {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(loginHtml);
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
res.end('Login page not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === '/api/auth/login' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseBody(req);
|
||||
if (!body.token) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Missing token' }));
|
||||
return;
|
||||
}
|
||||
const { payload } = await jwtVerify(body.token, JWKS, {
|
||||
issuer: `https://securetoken.google.com/${PROJECT_ID}`,
|
||||
audience: PROJECT_ID,
|
||||
});
|
||||
req.session.uid = payload.sub;
|
||||
req.session.email = payload.email;
|
||||
req.session.iat = Date.now();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} catch (err) {
|
||||
console.error('Token verification failed:', err.message);
|
||||
res.statusCode = 401;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Invalid token' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === '/api/auth/logout' && req.method === 'POST') {
|
||||
req.session = null;
|
||||
res.setHeader('Clear-Site-Data', '"cache", "storage"');
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.session || !req.session.uid) {
|
||||
const ext = extname(url);
|
||||
if (ext && ext !== '.html') {
|
||||
res.statusCode = 401;
|
||||
res.end('Unauthorized');
|
||||
} else {
|
||||
res.writeHead(302, { Location: '/login' });
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: serve injected index.html for HTML routes
|
||||
const ext = extname(url);
|
||||
if ((!ext || ext === '.html') && indexHtml) {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(indexHtml);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
} else if (indexHtml) {
|
||||
// No auth gate, but env config needs injection into HTML
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const url = req.url.split('?')[0];
|
||||
const ext = extname(url);
|
||||
if (!ext || ext === '.html') {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(indexHtml);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import authGatePlugin from './vite-plugin-auth-gate.js';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
|
|
@ -8,6 +9,7 @@ export default defineConfig({
|
|||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [
|
||||
authGatePlugin(),
|
||||
VitePWA({
|
||||
registerType: 'prompt',
|
||||
workbox: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue