Cuts release 0.24.0.
## Highlights
- SSO-managed accounts read-only for password / delete operations (UI + API). New `is_sso_user` flag derived from group memberships.
- Admin/Everyone system rows show `google_sync` chip + Workspace email subtitle when env-mapped.
- Origin pill vocabulary unified across `/admin/groups`, `/admin/access`, `/admin/users`, `/admin/users/{id}`, `/profile` (Admin yellow, Everyone gray, google_sync green, custom purple).
- Effective-access readout no longer short-circuits for admin users — always renders per-resource breakdown.
- Schema migration v18 drops stranded non-google memberships in env-mapped Admin/Everyone groups (cleans up v13's blanket Everyone backfill).
## Devin findings addressed
- _is_sso_user requires source='google_sync' on system-group branches (so v13 system_seed memberships in env-mapped Everyone don't lock out the admin).
- POST add-to-group returns correct origin via _derive_origin (matching GET).
- 8 customer-specific token instances (groupon.com / foundryai) replaced with vendor-neutral placeholders across templates, tests, and CHANGELOG.
- deriveDisplayName name-skip for canonical "Admin"/"Everyone" so an overlapping AGNES_GOOGLE_GROUP_PREFIX doesn't mangle the chip text.
See CHANGELOG [0.24.0] for full notes.
342 lines
10 KiB
HTML
342 lines
10 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Profile — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
/* /profile — read-only account view with Google Workspace group list.
|
|
Matches the card/hero vocabulary used on /tokens. */
|
|
body > .container { max-width: 960px; }
|
|
.profile-page {
|
|
max-width: 960px;
|
|
margin: 0 auto;
|
|
padding: 28px 8px 48px;
|
|
box-sizing: border-box;
|
|
font-family: var(--font-primary, 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif);
|
|
}
|
|
@media (max-width: 720px) {
|
|
.profile-page { padding: 20px 0 32px; }
|
|
}
|
|
|
|
.profile-hero {
|
|
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
|
|
border-radius: 14px;
|
|
padding: 28px 32px 24px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.2);
|
|
color: #fff;
|
|
}
|
|
.profile-hero .hero-eyebrow {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.8px;
|
|
color: rgba(255, 255, 255, 0.75);
|
|
margin-bottom: 8px;
|
|
}
|
|
.profile-hero .profile-title {
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
letter-spacing: -0.01em;
|
|
margin: 0 0 6px;
|
|
color: #fff;
|
|
}
|
|
.profile-hero .profile-subtitle {
|
|
font-size: 14px;
|
|
font-weight: 400;
|
|
color: rgba(255, 255, 255, 0.9);
|
|
margin: 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.section-card {
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
padding: 20px 24px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.section-card h3 {
|
|
margin: 0 0 14px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.4px;
|
|
color: var(--text-secondary, #6b7280);
|
|
}
|
|
|
|
.account-grid {
|
|
display: grid;
|
|
grid-template-columns: max-content 1fr;
|
|
gap: 10px 18px;
|
|
font-size: 14px;
|
|
}
|
|
.account-grid .k {
|
|
color: var(--text-secondary, #6b7280);
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.4px;
|
|
font-weight: 600;
|
|
align-self: center;
|
|
}
|
|
.account-grid .v {
|
|
color: var(--text-primary, #1A253C);
|
|
font-weight: 500;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.groups-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.group-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 10px 14px;
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 10px;
|
|
background: var(--background, #f9fafb);
|
|
}
|
|
.group-row .group-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text-primary, #1A253C);
|
|
min-width: 0;
|
|
flex: 0 1 auto;
|
|
}
|
|
.group-row .group-id {
|
|
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
|
font-size: 12px;
|
|
color: var(--text-secondary, #6b7280);
|
|
word-break: break-all;
|
|
flex: 1 1 auto;
|
|
}
|
|
/* Same chip vocabulary as /admin/users + /admin/users/{id}. Name match
|
|
wins over origin so an env-mapped Admin/Everyone keeps the canonical
|
|
color even when their server-side origin is google_sync. */
|
|
.group-chip {
|
|
display: inline-block;
|
|
padding: 3px 10px; border-radius: 999px;
|
|
font-size: 12px; font-weight: 500;
|
|
background: #ede9fe; color: #6d28d9; /* default = custom (purple) */
|
|
}
|
|
.group-chip.is-admin { background: #fef3c7; color: #92400e; font-weight: 600; }
|
|
.group-chip.is-everyone { background: #f3f4f6; color: #4b5563; }
|
|
.group-chip.is-google_sync { background: #dcfce7; color: #166534; }
|
|
.group-chip.is-custom { background: #ede9fe; color: #6d28d9; }
|
|
|
|
.empty-state {
|
|
padding: 20px 0 4px;
|
|
color: var(--text-secondary, #6b7280);
|
|
font-size: 14px;
|
|
line-height: 1.55;
|
|
}
|
|
.empty-state .empty-title {
|
|
font-weight: 600;
|
|
color: var(--text-primary, #1A253C);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.tokens-link-row {
|
|
margin-top: 18px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--border-light, #f3f4f6);
|
|
font-size: 13.5px;
|
|
color: var(--text-secondary, #6b7280);
|
|
}
|
|
.tokens-link-row a {
|
|
color: #0073D1;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
}
|
|
.tokens-link-row a:hover { text-decoration: underline; }
|
|
|
|
/* Effective roles section: chip cloud + grouped breakdown */
|
|
.role-chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin: 0 0 4px;
|
|
}
|
|
.role-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
background: rgba(0, 115, 209, 0.10);
|
|
color: #0073D1;
|
|
border: 1px solid rgba(0, 115, 209, 0.25);
|
|
}
|
|
.role-chip.is-core { background: rgba(16, 185, 129, 0.10); color: #047857; border-color: rgba(16, 185, 129, 0.25); }
|
|
|
|
.grant-list, .group-roles-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.grant-row, .group-roles-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 14px;
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 10px;
|
|
background: var(--background, #f9fafb);
|
|
flex-wrap: wrap;
|
|
}
|
|
.grant-row .grant-key, .group-roles-row .group-roles-name {
|
|
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text-primary, #1A253C);
|
|
}
|
|
.group-roles-row .group-roles-name {
|
|
font-family: var(--font-primary, 'Inter', system-ui, sans-serif);
|
|
}
|
|
.grant-row .grant-source {
|
|
font-size: 11.5px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.4px;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
background: rgba(107, 114, 128, 0.12);
|
|
color: var(--text-secondary, #6b7280);
|
|
font-weight: 600;
|
|
}
|
|
.grant-row .grant-source.source-direct { background: rgba(0, 115, 209, 0.12); color: #0073D1; }
|
|
.grant-row .grant-source.source-auto { background: rgba(245, 158, 11, 0.12); color: #b45309; }
|
|
.grant-row .grant-meta {
|
|
font-size: 12px;
|
|
color: var(--text-secondary, #6b7280);
|
|
margin-left: auto;
|
|
}
|
|
|
|
.group-roles-row .group-roles-arrow {
|
|
color: var(--text-secondary, #9ca3af);
|
|
font-weight: 400;
|
|
}
|
|
.group-roles-row .role-chips { margin: 0; }
|
|
|
|
.effective-help {
|
|
margin-top: 12px;
|
|
font-size: 12.5px;
|
|
color: var(--text-secondary, #6b7280);
|
|
line-height: 1.55;
|
|
}
|
|
</style>
|
|
|
|
<div class="profile-page">
|
|
<section class="profile-hero" aria-labelledby="profile-title">
|
|
<div class="hero-eyebrow">Your account</div>
|
|
<h2 class="profile-title" id="profile-title">Profile</h2>
|
|
<p class="profile-subtitle">Account details and your group memberships.</p>
|
|
</section>
|
|
|
|
<section class="section-card" aria-label="Account details">
|
|
<h3>Account</h3>
|
|
<div class="account-grid">
|
|
<span class="k">Email</span>
|
|
<span class="v">{{ user.email or "—" }}</span>
|
|
<span class="k">Name</span>
|
|
<span class="v">{{ user.name or "—" }}</span>
|
|
</div>
|
|
<div class="tokens-link-row">
|
|
Manage personal access tokens at <a href="/tokens">/tokens</a>.
|
|
</div>
|
|
</section>
|
|
|
|
<section class="section-card" aria-label="Group memberships">
|
|
<h3>Group memberships</h3>
|
|
{% if memberships and memberships | length > 0 %}
|
|
<ul class="groups-list" role="list">
|
|
{% for m in memberships %}
|
|
{# Color rule mirrors /admin/users + /admin/users/{id}: name match
|
|
on the seeded Admin/Everyone wins over origin so an env-mapped
|
|
system row keeps its canonical color. Hover over the chip
|
|
reveals the full Workspace email for shortened google_sync
|
|
display names. #}
|
|
{% if m.name == "Admin" %}
|
|
{% set chip_class = "is-admin" %}
|
|
{% elif m.name == "Everyone" %}
|
|
{% set chip_class = "is-everyone" %}
|
|
{% else %}
|
|
{% set chip_class = "is-" ~ (m.origin or "custom") %}
|
|
{% endif %}
|
|
<li class="group-row" role="listitem">
|
|
<span class="group-chip {{ chip_class }}" title="{{ m.name }}">{{ m.display_name or m.name }}</span>
|
|
<span class="group-id">via {{ m.source }}{% if m.added_at %} · added {{ m.added_at|string|truncate(16, true, '') }}{% endif %}</span>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<div class="empty-title">No group memberships</div>
|
|
<div>You're not a member of any groups yet. Ask an admin to add you, or wait for the next Google Workspace sync if you sign in via Google.</div>
|
|
</div>
|
|
{% endif %}
|
|
</section>
|
|
|
|
<section class="section-card" aria-label="Effective access">
|
|
<h3>Effective access</h3>
|
|
<div id="effective-access-host">
|
|
<div class="empty-state">
|
|
<div class="empty-title">Loading…</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const host = document.getElementById("effective-access-host");
|
|
fetch("/api/me/effective-access", { credentials: "include" })
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(data => {
|
|
if (!data) {
|
|
host.innerHTML = `<div class="empty-state">
|
|
<div class="empty-title">No data</div>
|
|
<div>Effective-access readout unavailable. Admins can view it at /admin/users/{{ user.id }}.</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
// Admins get the same explicit grant breakdown as everyone else —
|
|
// the runtime still gives Admin god-mode at authorization time, but
|
|
// this readout audits the actual grant graph (which Admin-group
|
|
// grants exist, which sibling groups carry them) instead of a flat
|
|
// "Full access" pill that hid the wiring.
|
|
if (!data.items || data.items.length === 0) {
|
|
host.innerHTML = `<div class="empty-state">
|
|
<div class="empty-title">No resource access yet</div>
|
|
<div>Resources are granted to groups; ask an admin to add you to one with the access you need.</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
let html = `<ul class="grant-list" role="list">`;
|
|
for (const it of data.items) {
|
|
const via = it.via_groups.map(g => `<span class="grant-source source-direct">${g.group_name}</span>`).join(" ");
|
|
html += `<li class="grant-row">
|
|
<span class="grant-key">${escapeHtml(it.resource_type)} · ${escapeHtml(it.resource_id)}</span>
|
|
${via}
|
|
</li>`;
|
|
}
|
|
html += `</ul>`;
|
|
host.innerHTML = html;
|
|
})
|
|
.catch(() => {
|
|
host.innerHTML = `<div class="empty-state"><div class="empty-title">Failed to load</div></div>`;
|
|
});
|
|
function escapeHtml(s) { const d = document.createElement("div"); d.textContent = s == null ? "" : String(s); return d.innerHTML; }
|
|
})();
|
|
</script>
|
|
{% endblock %}
|