Complete Phase 1: goals, cookie auth, profile editing

Close the remaining Phase 1 DoD gaps and reconcile the browser flow with
the auth layer.

Goals (5 -> 7 prompts):
- Add near-term (6-12mo) and long-term (3-5yr) goal prompts; collect raw
  text on the conversation and store AI-articulated goal summaries on the
  profile. Extractor articulates the person's own stated goals (mirror,
  not compass) and never fabricates. Alembic 003 adds the four columns.

Cookie-based browser sessions (fixes frontend<->auth desync):
- OAuth callback now sets httpOnly session cookies and redirects into the
  app instead of returning JSON. get_current_user gains a cookie fallback
  (X-API-Key -> Bearer -> cookie). refresh/logout read the refresh cookie
  and set/clear cookies. New shared auth.js (authedFetch) sends cookies and
  silently refreshes on 401. Static pages drop the bogus user_id and call
  the correct /me endpoints.

Profile editing (read/edit/affirm):
- PATCH /discovery/profile/me edits the prose (Ikigai summaries, overlap
  narrative, goals); owner-scoped, partial update, 409 when locked. Edit
  mode in profile.html with Save/Cancel.

Also: bump default model to claude-sonnet-4-6, align ports to 8011
(OAuth redirect, CORS), add COOKIE_SECURE/POST_LOGIN_REDIRECT config, and
refresh the README to match the shipped behavior.

Tests: 33 passing (added cookie-auth, profile-edit, goal-extraction cases;
factored a shared app_client fixture into conftest.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joel Salmon
2026-06-15 18:26:08 -05:00
parent b8f176bb31
commit 33674f92f4
22 changed files with 958 additions and 244 deletions
+65
View File
@@ -28,6 +28,13 @@ from app.models import RefreshToken, User
API_KEY_ADMIN_ID = "api-key-admin"
JWT_ALGORITHM = "HS256"
# Cookie names for the browser session. The refresh cookie is scoped to the
# auth path so it is only ever sent to /api/auth/* (refresh, logout), not to
# every discovery request.
ACCESS_COOKIE = "access_token"
REFRESH_COOKIE = "refresh_token"
REFRESH_COOKIE_PATH = "/api/auth"
oauth = OAuth()
oauth.register(
name="google",
@@ -55,6 +62,48 @@ def _refresh_days() -> int:
return int(os.getenv("JWT_REFRESH_DAYS", "7"))
def _cookie_secure() -> bool:
"""Secure-by-default. Set COOKIE_SECURE=false for local http dev, where
Secure cookies would never be sent over plain http://localhost."""
return os.getenv("COOKIE_SECURE", "true").strip().lower() not in (
"false",
"0",
"no",
)
def set_auth_cookies(
response, access_token: str, refresh_token: Optional[str] = None
) -> None:
"""Write the session as httpOnly cookies. Pass refresh_token only when it
rotates (login); a plain access refresh leaves the refresh cookie intact."""
secure = _cookie_secure()
response.set_cookie(
ACCESS_COOKIE,
access_token,
max_age=_access_minutes() * 60,
httponly=True,
secure=secure,
samesite="lax",
path="/",
)
if refresh_token is not None:
response.set_cookie(
REFRESH_COOKIE,
refresh_token,
max_age=_refresh_days() * 86400,
httponly=True,
secure=secure,
samesite="lax",
path=REFRESH_COOKIE_PATH,
)
def clear_auth_cookies(response) -> None:
response.delete_cookie(ACCESS_COOKIE, path="/")
response.delete_cookie(REFRESH_COOKIE, path=REFRESH_COOKIE_PATH)
def create_access_token(user_id: str, email: str) -> str:
now = datetime.now(timezone.utc)
payload = {
@@ -160,6 +209,22 @@ async def get_current_user(
request.state.auth_source = "jwt"
return user
# Browser session: the access token rides in an httpOnly cookie. An
# expired cookie decodes to 401, which the frontend recovers from by
# calling /api/auth/refresh.
cookie_token = request.cookies.get(ACCESS_COOKIE)
if cookie_token:
payload = decode_access_token(cookie_token)
user = await db.get(User, payload["sub"])
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User no longer exists",
)
request.state.user = user
request.state.auth_source = "cookie"
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
+1 -1
View File
@@ -49,7 +49,7 @@ app.add_middleware(SessionMiddleware, secret_key=_session_secret)
_cors_origins = [
o.strip()
for o in os.getenv(
"CORS_ALLOWED_ORIGINS", "http://localhost:8000"
"CORS_ALLOWED_ORIGINS", "http://localhost:8011"
).split(",")
if o.strip()
]
+16
View File
@@ -93,6 +93,15 @@ class DiscoveryConversation(Base):
)
prompt_future: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Phase 1 goal-articulation prompts: the person's own near- and long-term
# goals, in their own words.
prompt_goals_short: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
prompt_goals_long: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
class DiscoveryProfile(Base):
"""The extracted enneagram + Ikigai profile for a conversation."""
@@ -126,6 +135,13 @@ class DiscoveryProfile(Base):
Text, nullable=True
)
# AI-articulated goals: the person's own stated goals, clarified and
# connected to their Ikigai/enneagram pattern (mirror, never prescription).
short_term_goals: Mapped[Optional[str]] = mapped_column(
Text, nullable=True
)
long_term_goals: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
confidence_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
locked: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
+48 -24
View File
@@ -1,14 +1,18 @@
"""OAuth + JWT auth routes (mounted at /api/auth and /api/me)."""
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
from authlib.integrations.base_client import OAuthError
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import (
REFRESH_COOKIE,
clear_auth_cookies,
create_access_token,
email_domain_allowed,
find_or_create_google_user,
@@ -16,6 +20,7 @@ from app.auth import (
hash_refresh_token,
issue_refresh_token,
oauth,
set_auth_cookies,
)
from app.database import get_db
from app.models import (
@@ -50,15 +55,10 @@ class UserOut(BaseModel):
)
class TokenBundle(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserOut
class RefreshIn(BaseModel):
refresh_token: str
# Optional: browser clients send the refresh token via httpOnly cookie and
# omit the body entirely; API clients may still post it explicitly.
refresh_token: Optional[str] = None
class AccessOut(BaseModel):
@@ -122,19 +122,32 @@ async def auth_callback(request: Request, db: AsyncSession = Depends(get_db)):
refresh = await issue_refresh_token(
db, user, request.headers.get("user-agent")
)
return TokenBundle(
access_token=access,
refresh_token=refresh,
user=UserOut.from_orm_user(user),
)
# The browser drove this redirect flow, so hand the session back as
# httpOnly cookies and bounce into the app rather than dumping JSON.
redirect_to = os.getenv("POST_LOGIN_REDIRECT", "/static/discovery.html")
response = RedirectResponse(url=redirect_to, status_code=status.HTTP_303_SEE_OTHER)
set_auth_cookies(response, access, refresh)
return response
@router.post("/auth/refresh", response_model=AccessOut)
async def refresh_access_token(
body: RefreshIn, db: AsyncSession = Depends(get_db)
request: Request,
response: Response,
body: Optional[RefreshIn] = None,
db: AsyncSession = Depends(get_db),
):
raw_refresh = (body.refresh_token if body else None) or request.cookies.get(
REFRESH_COOKIE
)
if not raw_refresh:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No refresh token provided",
)
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
RefreshToken.token_hash == hash_refresh_token(raw_refresh)
)
row = (await db.execute(stmt)).scalar_one_or_none()
now = datetime.now(timezone.utc)
@@ -150,6 +163,8 @@ async def refresh_access_token(
detail="User no longer exists",
)
access = create_access_token(user.id, user.email)
# Refresh the access cookie in place; the refresh cookie is untouched.
set_auth_cookies(response, access)
return AccessOut(access_token=access)
@@ -162,16 +177,25 @@ def _expired(expires_at: datetime, now: datetime) -> bool:
@router.post("/auth/logout")
async def logout(
body: RefreshIn, db: AsyncSession = Depends(get_db)
request: Request,
response: Response,
body: Optional[RefreshIn] = None,
db: AsyncSession = Depends(get_db),
):
"""Revoke a single refresh token. Idempotent — unknown token returns 200."""
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(body.refresh_token)
"""Revoke a single refresh token and clear the session cookies. Idempotent
— an unknown/missing token still returns 200 with cookies cleared."""
raw_refresh = (body.refresh_token if body else None) or request.cookies.get(
REFRESH_COOKIE
)
row = (await db.execute(stmt)).scalar_one_or_none()
if row is not None and row.revoked_at is None:
row.revoked_at = datetime.now(timezone.utc)
await db.commit()
if raw_refresh:
stmt = select(RefreshToken).where(
RefreshToken.token_hash == hash_refresh_token(raw_refresh)
)
row = (await db.execute(stmt)).scalar_one_or_none()
if row is not None and row.revoked_at is None:
row.revoked_at = datetime.now(timezone.utc)
await db.commit()
clear_auth_cookies(response)
return {"status": "ok"}
+38 -1
View File
@@ -53,6 +53,8 @@ def _to_profile_response(
mission_summary=profile.mission_summary,
vocation_summary=profile.vocation_summary,
overlap_narrative=profile.overlap_narrative,
short_term_goals=profile.short_term_goals,
long_term_goals=profile.long_term_goals,
confidence=confidence,
locked=profile.locked,
extraction_notes=extraction_notes,
@@ -116,6 +118,8 @@ async def save_responses(
conversation.prompt_pull = payload.prompt_pull
conversation.prompt_recognition = payload.prompt_recognition
conversation.prompt_future = payload.prompt_future
conversation.prompt_goals_short = payload.prompt_goals_short
conversation.prompt_goals_long = payload.prompt_goals_long
await db.commit()
return schemas.RespondResponse(
@@ -139,6 +143,8 @@ async def complete_conversation(
"pull": conversation.prompt_pull or "",
"recognition": conversation.prompt_recognition or "",
"future": conversation.prompt_future or "",
"goals_short": conversation.prompt_goals_short or "",
"goals_long": conversation.prompt_goals_long or "",
}
if not any(text.strip() for text in responses.values()):
raise HTTPException(
@@ -146,7 +152,7 @@ async def complete_conversation(
)
api_key = os.getenv("ANTHROPIC_API_KEY")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-5")
model = os.getenv("ANTHROPIC_MODEL", "claude-sonnet-4-6")
try:
extractor = DiscoveryExtractor(api_key=api_key, model=model)
@@ -169,6 +175,8 @@ async def complete_conversation(
mission_summary=data.get("mission_summary"),
vocation_summary=data.get("vocation_summary"),
overlap_narrative=data.get("overlap_narrative"),
short_term_goals=data.get("short_term_goals"),
long_term_goals=data.get("long_term_goals"),
confidence_json=json.dumps(data.get("confidence", {})),
locked=False,
)
@@ -192,6 +200,35 @@ async def get_my_profile(
return _to_profile_response(profile)
@router.patch("/profile/me", response_model=schemas.ProfileResponse)
async def update_my_profile(
payload: schemas.ProfileUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Edit the prose of the latest profile. The person owns their words, so
they can revise any summary, the narrative, or their goals — but only
while the profile is unlocked. Affirming (locking) makes it final."""
profile = await _latest_profile(db, user.id)
if profile is None:
raise HTTPException(status_code=404, detail="No profile for this user")
if profile.locked:
raise HTTPException(
status_code=409,
detail="Profile is locked; it can no longer be edited.",
)
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
for field, value in updates.items():
setattr(profile, field, value)
await db.commit()
await db.refresh(profile)
return _to_profile_response(profile)
@router.put(
"/profile/me/confirm", response_model=schemas.ConfirmResponse
)
+20
View File
@@ -15,6 +15,8 @@ class RespondRequest(BaseModel):
prompt_pull: str = ""
prompt_recognition: str = ""
prompt_future: str = ""
prompt_goals_short: str = ""
prompt_goals_long: str = ""
class RespondResponse(BaseModel):
@@ -44,11 +46,27 @@ class ProfileResponse(BaseModel):
mission_summary: Optional[str] = None
vocation_summary: Optional[str] = None
overlap_narrative: Optional[str] = None
short_term_goals: Optional[str] = None
long_term_goals: Optional[str] = None
confidence: Optional[Confidence] = None
locked: bool = False
extraction_notes: Optional[str] = None
class ProfileUpdate(BaseModel):
"""Partial edit of a profile's prose. Only fields explicitly provided are
updated (see exclude_unset in the router). The AI's structural inference
(triad/type/wing/variant) and confidence are not editable here."""
love_summary: Optional[str] = None
strength_summary: Optional[str] = None
mission_summary: Optional[str] = None
vocation_summary: Optional[str] = None
overlap_narrative: Optional[str] = None
short_term_goals: Optional[str] = None
long_term_goals: Optional[str] = None
class ConfirmResponse(BaseModel):
status: str
@@ -63,5 +81,7 @@ class ConversationResponse(BaseModel):
prompt_pull: Optional[str] = None
prompt_recognition: Optional[str] = None
prompt_future: Optional[str] = None
prompt_goals_short: Optional[str] = None
prompt_goals_long: Optional[str] = None
model_config = {"from_attributes": True}
+14 -1
View File
@@ -11,7 +11,7 @@ from typing import Any, Dict
from anthropic import AsyncAnthropic
DEFAULT_MODEL = "claude-sonnet-4-5"
DEFAULT_MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 2000
# Ordered mapping of response keys -> the human-facing prompt label, used to
@@ -22,6 +22,8 @@ PROMPT_LABELS = {
"pull": "The Natural Pull",
"recognition": "The Recognition Moment",
"future": "The Future Pull",
"goals_short": "Near-Term Goals (612 months)",
"goals_long": "Long-Term Goals (35 years)",
}
# Keys the model must return for a profile to be considered well-formed.
@@ -36,6 +38,8 @@ REQUIRED_KEYS = (
"mission_summary",
"vocation_summary",
"overlap_narrative",
"short_term_goals",
"long_term_goals",
"confidence",
)
REQUIRED_CONFIDENCE_KEYS = ("triad", "type", "variant", "ikigai")
@@ -60,6 +64,13 @@ IKIGAI EXTRACTION RULES:
- Mission: what problem or need in the world their stories orbit around
- Vocation: where their strength and the world's need intersect with economic potential
GOAL ARTICULATION RULES:
- The last two responses are the person's own near-term (6-12 month) and long-term (3-5 year) goals.
- You are a MIRROR, not a compass. Articulate the goals THEY stated — clarify and sharpen their own words, connecting each goal to the Ikigai and enneagram pattern you found. Never invent goals, never prescribe a direction, never substitute your judgment for theirs.
- If a goal response is vague, reflect back the direction you can hear in it and note in extraction_notes that it is still forming — do not fill the gap with goals of your own.
- If a goal response is empty, return an empty string for that field. Do not fabricate.
- Write each goal summary directly to the person in second person (you/your), 2-4 sentences.
CONFIDENCE RULES:
- high: strong consistent signal across 2+ responses
- medium: signal present but only in one response or partially contradicted
@@ -79,6 +90,8 @@ Respond ONLY with valid JSON. No preamble, no explanation, no markdown fences.
"mission_summary": "2-3 sentence summary of what the world needs from them",
"vocation_summary": "2-3 sentence summary of what they can be paid for",
"overlap_narrative": "One paragraph (4-6 sentences) describing where their four Ikigai circles converge and how their enneagram type shapes that intersection. Write directly to the person in second person (you/your). Do not mention enneagram type numbers — describe the pattern in plain language.",
"short_term_goals": "2-4 sentences articulating the person's OWN stated near-term (6-12 month) goals, clarified and connected to their pattern. Empty string if they gave no goal.",
"long_term_goals": "2-4 sentences articulating the person's OWN stated long-term (3-5 year) goals, clarified and connected to their pattern. Empty string if they gave no goal.",
"confidence": {
"triad": "high | medium | low",
"type": "high | medium | low",
+33
View File
@@ -0,0 +1,33 @@
// Shared client-side auth helpers.
//
// The session lives entirely in httpOnly cookies that the server sets at
// /api/auth/callback, so there are no tokens for the page to store or read.
// We just send the cookies with every request and recover from access-token
// expiry by silently refreshing once before giving up and sending the user
// back through Google.
function redirectToLogin() {
window.location.href = "/api/auth/login";
}
// fetch() wrapper that always sends the session cookies. On a 401 it attempts
// a single silent refresh (the refresh cookie is scoped to /api/auth) and
// retries the original request; if that still fails, it bounces to login.
async function authedFetch(url, options = {}) {
const opts = { credentials: "include", ...options };
let res = await fetch(url, opts);
if (res.status !== 401) return res;
const refreshed = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (refreshed.ok) {
res = await fetch(url, opts);
if (res.status !== 401) return res;
}
redirectToLogin();
throw new Error("Not authenticated");
}
+19 -46
View File
@@ -11,6 +11,7 @@
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/auth.js"></script>
</head>
<body>
<!-- Question flow -->
@@ -67,44 +68,22 @@
text:
"If you knew you couldn't fail and money wasn't a factor, what would you spend the next five years building or doing?",
},
{
key: "prompt_goals_short",
title: "The Near Horizon",
text:
"Looking at the next 6 to 12 months, what do you most want to make progress on or accomplish? Say it in your own words.",
},
{
key: "prompt_goals_long",
title: "The Long Horizon",
text:
"Now stretch out 3 to 5 years. What do you want to have built, become, or changed by then?",
},
];
function createUserId() {
const webCrypto = globalThis.crypto;
if (webCrypto && typeof webCrypto.randomUUID === "function") {
return webCrypto.randomUUID();
}
if (webCrypto && typeof webCrypto.getRandomValues === "function") {
const bytes = new Uint8Array(16);
webCrypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) =>
b.toString(16).padStart(2, "0")
).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
12,
16
)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
return `local-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`;
}
// Persistent per-browser user id.
function getUserId() {
let id = localStorage.getItem("impactflow_user_id");
if (!id) {
id = createUserId();
localStorage.setItem("impactflow_user_id", id);
}
return id;
}
const userId = getUserId();
// The signed-in user is resolved server-side from the session cookie, so
// the page no longer mints or tracks a user id of its own.
const answers = new Array(PROMPTS.length).fill("");
let index = 0;
let conversationId = null;
@@ -155,11 +134,7 @@
});
async function startConversation() {
const res = await fetch("/discovery/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: userId }),
});
const res = await authedFetch("/discovery/start", { method: "POST" });
if (!res.ok) throw new Error("Could not start conversation");
const data = await res.json();
conversationId = data.conversation_id;
@@ -177,7 +152,7 @@
body[p.key] = answers[i];
});
const respondRes = await fetch(
const respondRes = await authedFetch(
`/discovery/${conversationId}/respond`,
{
method: "PUT",
@@ -187,7 +162,7 @@
);
if (!respondRes.ok) throw new Error("Could not save responses");
const completeRes = await fetch(
const completeRes = await authedFetch(
`/discovery/${conversationId}/complete`,
{ method: "POST" }
);
@@ -198,9 +173,7 @@
throw new Error(detail.detail || "Extraction failed");
}
window.location.href = `/static/profile.html?user_id=${encodeURIComponent(
userId
)}`;
window.location.href = "/static/profile.html";
} catch (err) {
el.loading.classList.remove("active");
el.flow.style.display = "block";
+156 -44
View File
@@ -11,6 +11,7 @@
rel="stylesheet"
/>
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/auth.js"></script>
</head>
<body>
<div class="wrap">
@@ -48,10 +49,6 @@
{ key: "vocation_summary", title: "What you can be paid for", icon: "$" },
];
function getParam(name) {
return new URLSearchParams(window.location.search).get(name);
}
function confClass(level) {
if (level === "high") return "high";
if (level === "medium") return "medium";
@@ -72,43 +69,76 @@
.replace(/>/g, "&gt;");
}
// The editable prose. Order/labels mirror the read view. The AI's
// structural read (triad/type/wing/variant) is shown but not edited here.
const GOAL_FIELDS = [
{ key: "short_term_goals", title: "Next 612 months" },
{ key: "long_term_goals", title: "Next 35 years" },
];
const content = document.getElementById("content");
let currentProfile = null;
async function load() {
const userId = getParam("user_id");
const content = document.getElementById("content");
if (!userId) {
content.innerHTML =
'<div class="error-box">No user id provided.</div>';
return;
}
let profile;
try {
const res = await fetch(
`/discovery/profile/${encodeURIComponent(userId)}`
);
const res = await authedFetch("/discovery/profile/me");
if (!res.ok) throw new Error("Profile not found");
profile = await res.json();
currentProfile = await res.json();
} catch (err) {
content.innerHTML = `<div class="error-box">Could not load your profile: ${escapeHtml(
err.message
)}</div>`;
return;
}
renderRead();
}
function renderRead() {
const profile = currentProfile;
const conf = profile.confidence || {};
const triad = TRIAD_INFO[profile.triad] || TRIAD_INFO.gut;
const ikigaiCards = IKIGAI.map((item) => {
return `
const ikigaiCards = IKIGAI.map(
(item) => `
<div class="card">
<h3>${dot(conf.ikigai)} ${item.icon} ${item.title}</h3>
<p>${escapeHtml(profile[item.key]) || "—"}</p>
</div>`;
}).join("");
</div>`
).join("");
const locked = profile.locked;
// Goals are the person's own articulated direction. Only show the
// section if at least one horizon has content.
const goals = GOAL_FIELDS.filter(
(g) => profile[g.key] && profile[g.key].trim()
);
const goalsBlock = goals.length
? `
<p class="section-label">Where you're headed</p>
<div class="ikigai-grid">${goals
.map(
(g) => `
<div class="card">
<h3>→ ${g.title}</h3>
<p>${escapeHtml(profile[g.key])}</p>
</div>`
)
.join("")}</div>
`
: "";
// Unlocked profiles can be edited and then affirmed; locked profiles are
// final and show neither control as actionable.
const actions = locked
? `<div class="confirm-row">
<button class="btn-primary confirmed" disabled>Profile confirmed ✓</button>
</div>`
: `<div class="nav">
<button class="btn-ghost" id="editBtn">Edit my words</button>
<button class="btn-primary" id="confirmBtn">This is me</button>
</div>`;
content.innerHTML = `
<div class="profile-narrative">${escapeHtml(
profile.overlap_narrative
@@ -122,32 +152,114 @@
<p>${triad.text}</p>
</div>
<div class="confirm-row">
<button class="btn-primary ${locked ? "confirmed" : ""}" id="confirmBtn" ${
locked ? "disabled" : ""
}>
${locked ? "Profile confirmed ✓" : "This is me"}
</button>
${goalsBlock}
${actions}
`;
if (locked) return;
document
.getElementById("editBtn")
.addEventListener("click", renderEdit);
const btn = document.getElementById("confirmBtn");
btn.addEventListener("click", async () => {
btn.disabled = true;
try {
const res = await authedFetch("/discovery/profile/me/confirm", {
method: "PUT",
});
if (!res.ok) throw new Error("confirm failed");
currentProfile.locked = true;
renderRead();
} catch (err) {
btn.disabled = false;
btn.textContent = "Try again";
}
});
}
function editField(key, label, value) {
return `
<div class="edit-field">
<label for="edit_${key}">${label}</label>
<textarea class="edit" id="edit_${key}">${escapeHtml(
value
)}</textarea>
</div>`;
}
function renderEdit() {
const profile = currentProfile;
const ikigaiFields = IKIGAI.map((item) =>
editField(item.key, `${item.icon} ${item.title}`, profile[item.key])
).join("");
const goalFields = GOAL_FIELDS.map((g) =>
editField(g.key, `${g.title}`, profile[g.key])
).join("");
content.innerHTML = `
<p class="section-label">Edit your words</p>
<p class="edit-help">These are your words to own. Revise anything that
doesn't sound like you, then save.</p>
${editField(
"overlap_narrative",
"Where it all comes together",
profile.overlap_narrative
)}
<div class="ikigai-grid">${ikigaiFields}</div>
<div class="ikigai-grid">${goalFields}</div>
<div id="editError"></div>
<div class="nav">
<button class="btn-ghost" id="cancelBtn">Cancel</button>
<button class="btn-primary" id="saveBtn">Save changes</button>
</div>
`;
const btn = document.getElementById("confirmBtn");
if (btn && !locked) {
btn.addEventListener("click", async () => {
btn.disabled = true;
try {
const res = await fetch(
`/discovery/profile/${encodeURIComponent(userId)}/confirm`,
{ method: "PUT" }
);
if (!res.ok) throw new Error("confirm failed");
btn.textContent = "Profile confirmed ✓";
btn.classList.add("confirmed");
} catch (err) {
btn.disabled = false;
btn.textContent = "Try again";
}
document.getElementById("cancelBtn").addEventListener("click", renderRead);
document.getElementById("saveBtn").addEventListener("click", saveEdits);
}
const EDITABLE_KEYS = [
"overlap_narrative",
...IKIGAI.map((i) => i.key),
...GOAL_FIELDS.map((g) => g.key),
];
async function saveEdits() {
const saveBtn = document.getElementById("saveBtn");
saveBtn.disabled = true;
const payload = {};
EDITABLE_KEYS.forEach((key) => {
payload[key] = document.getElementById(`edit_${key}`).value;
});
try {
const res = await authedFetch("/discovery/profile/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const detail = await res
.json()
.catch(() => ({ detail: "Could not save changes" }));
throw new Error(detail.detail || "Could not save changes");
}
currentProfile = await res.json();
renderRead();
} catch (err) {
saveBtn.disabled = false;
document.getElementById(
"editError"
).innerHTML = `<div class="error-box">${escapeHtml(
err.message
)}</div>`;
}
}
+25
View File
@@ -277,6 +277,31 @@ button:disabled {
cursor: default;
}
/* ---------- Profile edit mode ---------- */
.edit-help {
color: var(--navy-soft);
margin: 0 0 24px;
}
.edit-field {
margin-bottom: 18px;
}
.edit-field label {
display: block;
font-weight: 600;
font-size: 0.95rem;
color: var(--navy);
margin-bottom: 8px;
}
textarea.edit {
min-height: 120px;
padding: 14px 16px;
font-size: 0.98rem;
}
.error-box {
background: #fdecea;
border: 1px solid var(--red);