Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion routes/api_token_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from core.database import get_db_session, ApiToken
from core.middleware import require_admin
from src.auth_helpers import get_current_user
from src.auth_helpers import get_current_user, require_user

MAX_NAME_LEN = 100
DEFAULT_SCOPES = "chat"
Expand Down Expand Up @@ -206,4 +206,82 @@ def delete_token(request: Request, token_id: str):
_invalidate_cache(request)
return {"status": "deleted"}

# ── Self-serve endpoints (cookie-only, owner forced to current user) ──

@router.get("/tokens/self")
def list_self_tokens(request: Request):
"""List the current user's own API tokens. Cookie session only."""
user = require_user(request)
with get_db_session() as db:
tokens = db.query(ApiToken).filter(
ApiToken.owner == user, ApiToken.is_active == True # noqa: E712
).all()
return [
{
"id": t.id,
"name": t.name,
"owner": getattr(t, "owner", None),
"token_prefix": t.token_prefix,
"scopes": [s.strip() for s in (getattr(t, "scopes", "") or DEFAULT_SCOPES).split(",") if s.strip()],
"is_active": t.is_active,
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in tokens
]

@router.post("/tokens/self")
def create_self_token(
request: Request,
name: str = Form(""),
scopes: str = Form(None),
profile: str = Form(None),
):
"""Create an API token for the current user. Cookie session only."""
user = require_user(request)
name = name.strip()[:MAX_NAME_LEN]
if not name:
raise HTTPException(400, "Token name is required")
scope_list = _normalize_scopes(scopes, profile)
scopes_value = ",".join(scope_list)

raw_token = "ody_" + secrets.token_urlsafe(32)
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
token_id = str(uuid.uuid4())[:8]

with get_db_session() as db:
db.add(ApiToken(
id=token_id,
owner=user,
name=name,
token_hash=token_hash,
token_prefix=raw_token[:8],
scopes=scopes_value,
is_active=True,
))
_invalidate_cache(request)

return {
"id": token_id,
"name": name,
"owner": user,
"token": raw_token,
"token_prefix": raw_token[:8],
"scopes": scope_list,
}

@router.delete("/tokens/self/{token_id}")
def delete_self_token(request: Request, token_id: str):
"""Delete one of the current user's own API tokens. Cookie session only."""
user = require_user(request)
with get_db_session() as db:
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
# Return 404 for both "not found" and "not yours" so callers
# can't probe for the existence of another user's token ids.
if not token or token.owner != user:
raise HTTPException(404, "Token not found")
db.delete(token)
_invalidate_cache(request)
return {"status": "deleted"}

return router
28 changes: 28 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,34 @@ <h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentC
<!-- Populated by JS -->
</div>
</div>
<div class="admin-card" id="settings-api-tokens-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 1a3 3 0 0 0-3 3v2H7a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9a3 3 0 0 0-3-3h-2V4a3 3 0 0 0-3-3z"/></svg>Personal API Tokens</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Create tokens to access your Odysseus data from scripts, apps, or external tools. Tokens are scoped — only grant the permissions your use case needs.</div>
<div id="settings-api-tokens-list" style="margin-bottom:10px;">
<!-- Populated by JS -->
</div>
<div id="settings-api-tokens-create" style="display:none;border-top:1px solid var(--border);padding-top:10px;margin-top:4px;">
<div style="display:flex;gap:8px;margin-bottom:8px;">
<input id="settings-api-token-name" type="text" placeholder="Token name (e.g. 'My backup script')" style="flex:1;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
<button class="admin-btn-add" id="settings-api-token-create-btn" style="white-space:nowrap;">Create</button>
</div>
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Select which data this token can access. Leave all unchecked for chat-only access.</div>
<div id="settings-api-token-scopes" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:4px 12px;">
<!-- Populated by JS -->
</div>
<div id="settings-api-token-create-msg" style="font-size:11px;margin-top:6px;min-height:16px;"></div>
<div id="settings-api-token-reveal" style="display:none;margin-top:8px;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;">
<div style="font-size:11px;opacity:0.5;margin-bottom:4px;">Copy this token now — it won't be shown again.</div>
<div style="display:flex;align-items:center;gap:6px;">
<code id="settings-api-token-value" style="flex:1;padding:4px 8px;font-size:11px;background:var(--bg);border:1px solid var(--border);border-radius:4px;word-break:break-all;user-select:all;"></code>
<button class="admin-btn-sm" id="settings-api-token-copy-btn" title="Copy to clipboard" style="flex-shrink:0;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</div>
</div>
</div>
<button class="admin-btn-add" id="settings-api-token-new-btn" style="margin-top:4px;">+ New Token</button>
</div>
</div>

<!-- ═══ EMAIL TAB ═══ -->
Expand Down
166 changes: 166 additions & 0 deletions static/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,169 @@ async function initShortcuts() {
/* ═══════════════════════════════════════════
INIT & REFRESH
═══════════════════════════════════════════ */
// ── Personal API Tokens (Account tab) ──
const SELF_TOKEN_SCOPES = [
{ key: 'chat', label: 'Chat', detail: 'Chat and companion access' },
{ key: 'todos:read', label: 'Todos', detail: 'Read notes and checklists' },
{ key: 'todos:write', label: 'Todos write', detail: 'Create, update, delete, and toggle todo items' },
{ key: 'documents:read', label: 'Documents', detail: 'Read documents' },
{ key: 'documents:write', label: 'Documents write', detail: 'Create and update draft documents' },
{ key: 'email:read', label: 'Email', detail: 'Read email' },
{ key: 'email:draft', label: 'Email drafts', detail: 'Create email reply drafts' },
{ key: 'email:send', label: 'Email send', detail: 'Send email directly' },
{ key: 'calendar:read', label: 'Calendar', detail: 'Read calendar events' },
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
{ key: 'memory:read', label: 'Memory', detail: 'Read memory' },
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory' },
{ key: 'cookbook:read', label: 'Cookbook', detail: 'Read model inventory and presets' },
{ key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Start and stop model servers' },
];

function initSelfApiTokens() {
const listEl = el('settings-api-tokens-list');
const createPanel = el('settings-api-tokens-create');
const newBtn = el('settings-api-token-new-btn');
const createBtn = el('settings-api-token-create-btn');
const nameInput = el('settings-api-token-name');
const scopesEl = el('settings-api-token-scopes');
const msgEl = el('settings-api-token-create-msg');
const revealEl = el('settings-api-token-reveal');
const tokenValueEl = el('settings-api-token-value');
const copyBtn = el('settings-api-token-copy-btn');

if (!listEl || !newBtn) return;

// Build scope checkboxes once
scopesEl.innerHTML = SELF_TOKEN_SCOPES.map(s => `
<label style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:12px;cursor:pointer;">
<input type="checkbox" class="self-token-scope" value="${esc(s.key)}" title="${esc(s.detail)}">
<span>${esc(s.label)}</span>
</label>
`).join('');

async function loadTokens() {
try {
const res = await fetch('/api/tokens/self', { credentials: 'same-origin' });
const tokens = await res.json();
if (!tokens.length) {
listEl.innerHTML = '<div style="font-size:11px;opacity:0.4;padding:4px 0;">No tokens yet</div>';
return;
}
listEl.innerHTML = tokens.map(t => {
const scopes = (t.scopes || []).join(', ') || 'chat';
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb, var(--fg) 3%, transparent);">
<div style="flex:1;min-width:0;">
<div style="font-size:12px;font-weight:600;">${esc(t.name)}</div>
<div style="font-size:10px;opacity:0.5;display:flex;gap:8px;flex-wrap:wrap;">
<span>${esc(t.token_prefix)}...</span>
<span>${esc(scopes)}</span>
${t.last_used_at ? `<span>Last used ${new Date(t.last_used_at).toLocaleDateString()}</span>` : '<span>Never used</span>'}
</div>
</div>
<button class="admin-btn-delete self-token-revoke" data-token-id="${esc(t.id)}" title="Revoke token" style="flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</button>
</div>`;
}).join('');

// Wire revoke buttons
listEl.querySelectorAll('.self-token-revoke').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const tokenId = btn.dataset.tokenId;
if (!await window.styledConfirm('Revoke this token? Any scripts or apps using it will stop working.', { confirmText: 'Revoke', danger: true })) return;
try {
const r = await fetch(`/api/tokens/self/${tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
if (r.ok) loadTokens();
} catch (_) {}
});
});
} catch (_) {
listEl.innerHTML = '<div style="font-size:11px;opacity:0.4;">Could not load tokens</div>';
}
}

loadTokens();

// New token button
newBtn.addEventListener('click', () => {
createPanel.style.display = '';
newBtn.style.display = 'none';
nameInput.value = '';
nameInput.focus();
revealEl.style.display = 'none';
msgEl.textContent = '';
scopesEl.querySelectorAll('.self-token-scope').forEach(cb => { cb.checked = false; });
});

// Cancel create — hide panel, show button
const cancelCreate = () => {
createPanel.style.display = 'none';
newBtn.style.display = '';
revealEl.style.display = 'none';
msgEl.textContent = '';
};

// Create token
createBtn.addEventListener('click', async () => {
const name = nameInput.value.trim();
if (!name) { msgEl.textContent = 'Token name is required'; msgEl.style.color = 'var(--red)'; return; }
const checked = Array.from(scopesEl.querySelectorAll('.self-token-scope:checked')).map(cb => cb.value);
const fd = new FormData();
fd.append('name', name);
if (checked.length) fd.append('scopes', checked.join(','));
msgEl.textContent = '';
msgEl.style.color = '';
createBtn.disabled = true;
try {
const res = await fetch('/api/tokens/self', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json();
if (res.ok) {
tokenValueEl.textContent = data.token;
revealEl.style.display = '';
nameInput.value = '';
scopesEl.querySelectorAll('.self-token-scope').forEach(cb => { cb.checked = false; });
loadTokens();
} else {
msgEl.textContent = data.detail || 'Failed';
msgEl.style.color = 'var(--red)';
}
} catch (_) {
msgEl.textContent = 'Request failed';
msgEl.style.color = 'var(--red)';
} finally {
createBtn.disabled = false;
}
});

// Copy button
if (copyBtn) {
const COPY_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const CHECK_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
copyBtn.addEventListener('click', () => {
const val = tokenValueEl.textContent;
navigator.clipboard.writeText(val).then(() => {
copyBtn.innerHTML = CHECK_ICON;
copyBtn.style.color = 'var(--accent, var(--red))';
copyBtn.style.opacity = '1';
setTimeout(() => {
copyBtn.innerHTML = COPY_ICON;
copyBtn.style.color = '';
copyBtn.style.opacity = '';
}, 1600);
});
});
}

// Allow Enter key in name input to create
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
createBtn.click();
}
});
}

function initAccount() {
// Populate user info
fetch('/api/auth/status', { credentials: 'same-origin' })
Expand Down Expand Up @@ -2287,6 +2450,9 @@ function initAccount() {
render2FA();
}

// Personal API Tokens
initSelfApiTokens();

// Logout
const logoutBtn = el('settings-logout-btn');
if (logoutBtn) {
Expand Down
Loading