feat: multi-user auth — local accounts, settings page, OAuth prep
Phase 1 foundation: - DB: users table extended (password_hash, is_active, login_attempts, locked_until) - DB: user_oauth_tokens table (user_id, provider, access_token, refresh_token) - password_utils.py: SHA-256+salt hashing, verify, rate-limit lock check - auth.py: POST /auth/register + POST /auth/local-login + /auth/login?provider=local - Login page: tabs Login/Register with Gitea OAuth button - settings.html: profile (name/password), forges, API tokens, sessions - ALTER TABLE migrations for existing DBs
This commit is contained in:
@@ -27,7 +27,12 @@ def init_db():
|
||||
full_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
is_admin BOOLEAN NOT NULL DEFAULT 0,
|
||||
password_hash TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_login TIMESTAMP,
|
||||
login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -39,6 +44,19 @@ def init_db():
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_oauth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
provider TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
expires_at TIMESTAMP,
|
||||
instance_url TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, provider)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS boards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_owner TEXT NOT NULL,
|
||||
@@ -304,6 +322,12 @@ def init_db():
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN published INTEGER DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# v2.2: Auth locale
|
||||
for col in ["password_hash", "is_active", "last_login", "login_attempts", "locked_until"]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE users ADD COLUMN {col} {'TEXT' if col in ('password_hash','last_login','locked_until') else 'INTEGER NOT NULL DEFAULT ' + ('1' if col=='is_active' else '0')}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Password hashing and login security utilities."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using SHA-256 + random salt (16 bytes).
|
||||
Format: salt_hex:hash_hex (64 + 64 = 128 chars)
|
||||
Fallback for bcrypt — we use SHA-256 for SQLite simplicity
|
||||
but with proper salt per password."""
|
||||
salt = secrets.token_hex(16)
|
||||
h = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
return f"{salt}:{h}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
"""Verify a password against its stored hash."""
|
||||
try:
|
||||
salt, h = stored.split(":", 1)
|
||||
expected = hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
return h == expected
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def is_locked(locked_until: str | None) -> bool:
|
||||
"""Check if account is temporarily locked."""
|
||||
if not locked_until:
|
||||
return False
|
||||
try:
|
||||
return float(locked_until) > time.time()
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
+161
-2
@@ -14,10 +14,75 @@ from app.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
|
||||
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — Login</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;}
|
||||
.login-box{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:400px;}
|
||||
.login-box h1{font-size:24px;margin-bottom:8px;}
|
||||
.login-box p{color:rgba(255,255,255,.5);font-size:14px;margin-bottom:24px;}
|
||||
.form-group{margin-bottom:16px;}
|
||||
.form-group label{display:block;font-size:13px;color:rgba(255,255,255,.6);margin-bottom:6px;}
|
||||
.form-group input{width:100%;padding:10px 12px;background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:14px;outline:none;}
|
||||
.form-group input:focus{border-color:#2383E2;box-shadow:0 0 0 1px #2383E2;}
|
||||
.btn{width:100%;padding:12px;border:none;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;margin-top:8px;}
|
||||
.btn-primary{background:#2383E2;color:#fff;}
|
||||
.btn-primary:hover{background:#2C8CEB;}
|
||||
.btn-secondary{background:#333;color:#fff;margin-top:12px;}
|
||||
.btn-secondary:hover{background:#444;}
|
||||
.tabs{display:flex;gap:0;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,.08);}
|
||||
.tab{flex:1;text-align:center;padding:12px;cursor:pointer;font-size:14px;color:rgba(255,255,255,.5);border-bottom:2px solid transparent;background:none;border-top:none;border-left:none;border-right:none;}
|
||||
.tab.active{color:#fff;border-bottom-color:#2383E2;}
|
||||
.error{background:rgba(255,80,80,.15);color:#ff5050;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
||||
.success{background:rgba(80,255,80,.15);color:#50ff50;padding:10px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none;}
|
||||
.oauth-section{margin-top:20px;border-top:1px solid rgba(255,255,255,.08);padding-top:20px;}
|
||||
.oauth-btn{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:#2A2A2A;color:#fff;}
|
||||
.oauth-btn:hover{background:#333;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>FlowDeck</h1>
|
||||
<p>Login or create an account to continue</p>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('login')" id="tab-login">Login</button>
|
||||
<button class="tab" onclick="switchTab('register')" id="tab-register">Register</button>
|
||||
</div>
|
||||
<div id="error-msg" class="error"></div>
|
||||
<div id="success-msg" class="success"></div>
|
||||
<form id="login-form" onsubmit="handleLogin(event)">
|
||||
<div class="form-group"><label>Email</label><input type="email" id="email" required></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="password" required minlength="6"></div>
|
||||
<div class="form-group" id="name-group" style="display:none"><label>Name</label><input type="text" id="name"></div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Login</button>
|
||||
</form>
|
||||
<div class="oauth-section">
|
||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'">🔗 Login with Gitea</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let mode='login';
|
||||
function switchTab(t){mode=t;document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));document.getElementById('tab-'+t).classList.add('active');document.getElementById('name-group').style.display=t==='register'?'block':'none';document.getElementById('submit-btn').textContent=t==='register'?'Register':'Login';document.getElementById('error-msg').style.display='none';document.getElementById('success-msg').style.display='none';}
|
||||
async function handleLogin(e){e.preventDefault();const email=document.getElementById('email').value;const password=document.getElementById('password').value;const name=document.getElementById('name').value;const url=mode==='register'?'/auth/register':'/auth/local-login';const body={email,password};if(mode==='register')body.name=name;try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok){document.getElementById('error-msg').textContent=d.error||'Error';document.getElementById('error-msg').style.display='block';return;}if(d.status==='ok'){window.location='/';}}catch(err){document.getElementById('error-msg').textContent='Network error';document.getElementById('error-msg').style.display='block';}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
"""Redirect to Gitea OAuth2 authorize page."""
|
||||
async def login(request: Request, provider: str = Query("gitea")):
|
||||
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
if provider == "local":
|
||||
from fastapi.responses import HTMLResponse
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
||||
|
||||
# OAuth flow
|
||||
if not gitea_oauth.enabled:
|
||||
# Fallback: use global token, create a fake session
|
||||
from app.db import get_conn
|
||||
@@ -39,6 +104,100 @@ async def login(request: Request):
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: Request):
|
||||
"""Register a new local account."""
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
import json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
||||
|
||||
if not email or not password:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
||||
if len(password) < 6:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT id FROM users WHERE login=?", (email,)).fetchone()
|
||||
if existing:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"error": "Account already exists"}, status_code=409)
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, password_hash) VALUES (?, ?, ?, ?)",
|
||||
(email, name, email, hash_password(password)),
|
||||
)
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
user_data = dict(user)
|
||||
session = SessionManager.create_session(user_data)
|
||||
from fastapi.responses import JSONResponse
|
||||
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
"""Login with email + password."""
|
||||
from app.db import get_conn
|
||||
from app.password_utils import verify_password, is_locked
|
||||
from fastapi.responses import JSONResponse
|
||||
import json, time
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
|
||||
if not email or not password:
|
||||
return JSONResponse({"error": "Email and password required"}, status_code=400)
|
||||
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone()
|
||||
if not user:
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
ud = dict(user)
|
||||
if not ud.get("is_active"):
|
||||
return JSONResponse({"error": "Account disabled"}, status_code=403)
|
||||
if is_locked(ud.get("locked_until")):
|
||||
return JSONResponse({"error": "Account temporarily locked. Try again later."}, status_code=423)
|
||||
|
||||
if not verify_password(password, ud.get("password_hash", "")):
|
||||
with get_conn() as conn:
|
||||
attempts = (ud.get("login_attempts", 0) or 0) + 1
|
||||
lock = None
|
||||
if attempts >= 5:
|
||||
lock = str(time.time() + 900) # 15 min lock
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=?, locked_until=? WHERE id=?",
|
||||
(attempts, lock, ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return JSONResponse({"error": "Invalid credentials"}, status_code=401)
|
||||
|
||||
# Successful login
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET login_attempts=0, locked_until=NULL, last_login=? WHERE id=?",
|
||||
(str(time.time()), ud["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
session = SessionManager.create_session(ud)
|
||||
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
request: Request,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Account Settings{% endblock %}
|
||||
{% block page_icon %}⚙️{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">
|
||||
Settings
|
||||
</span>
|
||||
<span class="page-title-topbar">Account Settings</span>
|
||||
</div>
|
||||
<div class="topbar-right"></div>
|
||||
</header>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.settings-layout{max-width:700px;margin:0 auto;padding:40px 24px;}
|
||||
.settings-section{margin-bottom:32px;}
|
||||
.settings-section h2{font-size:14px;color:rgba(255,255,255,.5);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;font-weight:500;}
|
||||
.settings-card{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;}
|
||||
.settings-row{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid rgba(255,255,255,.06);}
|
||||
.settings-row:last-child{border-bottom:none;}
|
||||
.settings-row-left{flex:1;}
|
||||
.settings-row-label{font-size:14px;color:#fff;}
|
||||
.settings-row-desc{font-size:12px;color:rgba(255,255,255,.45);margin-top:2px;}
|
||||
.settings-row-value{font-size:13px;color:rgba(255,255,255,.5);margin-right:12px;}
|
||||
.settings-input{background:#2A2A2A;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 12px;color:#fff;font-size:13px;width:220px;outline:none;}
|
||||
.settings-input:focus{border-color:#2383E2;}
|
||||
.settings-btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
|
||||
.settings-btn-primary{background:#2383E2;color:#fff;}
|
||||
.settings-btn-primary:hover{background:#2C8CEB;}
|
||||
.settings-btn-danger{background:rgba(255,80,80,.15);color:#ff5050;}
|
||||
.settings-btn-danger:hover{background:rgba(255,80,80,.25);}
|
||||
.settings-btn-secondary{background:#333;color:#fff;}
|
||||
.settings-btn-secondary:hover{background:#444;}
|
||||
.inline-form{display:flex;align-items:center;gap:8px;}
|
||||
.forge-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:#2A2A2A;border-radius:6px;font-size:12px;}
|
||||
.connected{color:#50ff50;}
|
||||
.disconnected{color:rgba(255,255,255,.3);}
|
||||
</style>
|
||||
|
||||
<div class="settings-layout" x-data="settingsPage()">
|
||||
|
||||
<!-- Profile -->
|
||||
<div class="settings-section">
|
||||
<h2>Profile</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Email</div>
|
||||
<div class="settings-row-desc">Used for login and notifications</div>
|
||||
</div>
|
||||
<div class="settings-row-value">{{ user.email or user.login or '' }}</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Name</div>
|
||||
<div class="settings-row-desc">Display name</div>
|
||||
</div>
|
||||
<div class="inline-form">
|
||||
<input class="settings-input" x-model="name" :value="name" placeholder="Your name">
|
||||
<button class="settings-btn settings-btn-primary" @click="saveName">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Password</div>
|
||||
<div class="settings-row-desc">Change your password</div>
|
||||
</div>
|
||||
<div class="inline-form">
|
||||
<input class="settings-input" type="password" x-model="newPassword" placeholder="New password">
|
||||
<button class="settings-btn settings-btn-primary" @click="savePassword">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Forges -->
|
||||
<div class="settings-section">
|
||||
<h2>Connected Forges</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">🔗 Gitea</div>
|
||||
<div class="settings-row-desc">Connect to your Gitea instance</div>
|
||||
</div>
|
||||
{% if gitea_connected %}
|
||||
<span class="forge-badge connected">✅ Connected</span>
|
||||
<button class="settings-btn settings-btn-danger" @click="disconnect('gitea')">Disconnect</button>
|
||||
{% else %}
|
||||
<button class="settings-btn settings-btn-primary" onclick="window.location='/auth/login?provider=gitea'">Connect</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">🐙 GitHub</div>
|
||||
<div class="settings-row-desc">Connect to GitHub</div>
|
||||
</div>
|
||||
{% if github_connected %}
|
||||
<span class="forge-badge connected">✅ Connected</span>
|
||||
<button class="settings-btn settings-btn-danger" @click="disconnect('github')">Disconnect</button>
|
||||
{% else %}
|
||||
<button class="settings-btn settings-btn-secondary">
|
||||
Coming soon
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Tokens -->
|
||||
<div class="settings-section">
|
||||
<h2>API Tokens</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Generate API Token</div>
|
||||
<div class="settings-row-desc">For programmatic access to FlowDeck API</div>
|
||||
</div>
|
||||
<button class="settings-btn settings-btn-primary" @click="generateToken">Generate</button>
|
||||
</div>
|
||||
<template x-if="token">
|
||||
<div class="settings-row" style="background:#1a2a1a">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label" style="font-family:monospace;font-size:12px;word-break:break-all" x-text="token"></div>
|
||||
<div class="settings-row-desc" style="color:#50ff50">Copy this token now — it won't be shown again</div>
|
||||
</div>
|
||||
<button class="settings-btn settings-btn-primary" @click="copyToken">Copy</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="settings-section">
|
||||
<h2>Active Sessions</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row-left">
|
||||
<div class="settings-row-label">Current session</div>
|
||||
<div class="settings-row-desc">You are logged in</div>
|
||||
</div>
|
||||
<button class="settings-btn settings-btn-danger" onclick="window.location='/auth/logout'">Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function settingsPage() {
|
||||
return {
|
||||
name: '{{ user.full_name or '' }}',
|
||||
newPassword: '',
|
||||
token: '',
|
||||
async saveName() {
|
||||
await fetch('/api/user/profile', {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({full_name:this.name})});
|
||||
alert('Name saved');
|
||||
},
|
||||
async savePassword() {
|
||||
if (!this.newPassword || this.newPassword.length < 6) { alert('Password must be at least 6 characters'); return; }
|
||||
await fetch('/api/user/password', {method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:this.newPassword})});
|
||||
this.newPassword = '';
|
||||
alert('Password updated');
|
||||
},
|
||||
async generateToken() {
|
||||
const r = await fetch('/api/user/token', {method:'POST'});
|
||||
const d = await r.json();
|
||||
this.token = d.token;
|
||||
},
|
||||
copyToken() {
|
||||
navigator.clipboard?.writeText(this.token);
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = this.token; ta.style.position='fixed'; ta.style.opacity='0';
|
||||
document.body.appendChild(ta); ta.select(); document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
alert('Token copied to clipboard');
|
||||
},
|
||||
async disconnect(provider) {
|
||||
if (!confirm('Disconnect ' + provider + '?')) return;
|
||||
await fetch('/api/user/forge/' + provider, {method:'DELETE'});
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user