mirror of
https://github.com/computerim/impactflow-discovery.git
synced 2026-08-27 05:00:37 +00:00
b8f176bb31
Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.
Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:
- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
/api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
/api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log
Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
224 lines
6.9 KiB
HTML
224 lines
6.9 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>ImpactFlow — Self Discovery</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap"
|
|
rel="stylesheet"
|
|
/>
|
|
<link rel="stylesheet" href="/static/style.css" />
|
|
</head>
|
|
<body>
|
|
<!-- Question flow -->
|
|
<div class="wrap" id="flow">
|
|
<div class="brand">ImpactFlow · Self Discovery</div>
|
|
<div class="progress" id="progress">1 of 5</div>
|
|
<h2 class="prompt-title" id="promptTitle"></h2>
|
|
<p class="prompt-text" id="promptText"></p>
|
|
<textarea
|
|
id="answer"
|
|
placeholder="Take your time. Tell the whole story…"
|
|
></textarea>
|
|
<div class="nav">
|
|
<button class="btn-ghost" id="backBtn">Back</button>
|
|
<button class="btn-primary" id="nextBtn">Next</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div class="loading" id="loading">
|
|
<div class="spinner"></div>
|
|
<p>Reading your story…</p>
|
|
</div>
|
|
|
|
<script>
|
|
const PROMPTS = [
|
|
{
|
|
key: "prompt_alive",
|
|
title: "The Alive Moment",
|
|
text:
|
|
"Tell me about a time you felt most alive and useful. What were you doing, who was involved, and what made it matter?",
|
|
},
|
|
{
|
|
key: "prompt_friction",
|
|
title: "The Friction Moment",
|
|
text:
|
|
"Describe a situation where something felt deeply wrong or unfair. What was it, and what did you do about it?",
|
|
},
|
|
{
|
|
key: "prompt_pull",
|
|
title: "The Natural Pull",
|
|
text:
|
|
"What do you find yourself doing or thinking about in your free time, even when you're supposed to be doing something else?",
|
|
},
|
|
{
|
|
key: "prompt_recognition",
|
|
title: "The Recognition Moment",
|
|
text:
|
|
"When have you felt most seen or valued — and what were you being recognized for?",
|
|
},
|
|
{
|
|
key: "prompt_future",
|
|
title: "The Future Pull",
|
|
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?",
|
|
},
|
|
];
|
|
|
|
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();
|
|
const answers = new Array(PROMPTS.length).fill("");
|
|
let index = 0;
|
|
let conversationId = null;
|
|
|
|
const el = {
|
|
progress: document.getElementById("progress"),
|
|
title: document.getElementById("promptTitle"),
|
|
text: document.getElementById("promptText"),
|
|
answer: document.getElementById("answer"),
|
|
back: document.getElementById("backBtn"),
|
|
next: document.getElementById("nextBtn"),
|
|
flow: document.getElementById("flow"),
|
|
loading: document.getElementById("loading"),
|
|
};
|
|
|
|
function render() {
|
|
const p = PROMPTS[index];
|
|
el.progress.textContent = `${index + 1} of ${PROMPTS.length}`;
|
|
el.title.textContent = p.title;
|
|
el.text.textContent = p.text;
|
|
el.answer.value = answers[index];
|
|
el.back.style.visibility = index === 0 ? "hidden" : "visible";
|
|
el.next.textContent =
|
|
index === PROMPTS.length - 1 ? "Submit" : "Next";
|
|
el.answer.focus();
|
|
}
|
|
|
|
function saveCurrent() {
|
|
answers[index] = el.answer.value;
|
|
}
|
|
|
|
el.back.addEventListener("click", () => {
|
|
saveCurrent();
|
|
if (index > 0) {
|
|
index -= 1;
|
|
render();
|
|
}
|
|
});
|
|
|
|
el.next.addEventListener("click", async () => {
|
|
saveCurrent();
|
|
if (index < PROMPTS.length - 1) {
|
|
index += 1;
|
|
render();
|
|
} else {
|
|
await submit();
|
|
}
|
|
});
|
|
|
|
async function startConversation() {
|
|
const res = await fetch("/discovery/start", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ user_id: userId }),
|
|
});
|
|
if (!res.ok) throw new Error("Could not start conversation");
|
|
const data = await res.json();
|
|
conversationId = data.conversation_id;
|
|
}
|
|
|
|
async function submit() {
|
|
el.flow.style.display = "none";
|
|
el.loading.classList.add("active");
|
|
|
|
try {
|
|
if (!conversationId) await startConversation();
|
|
|
|
const body = {};
|
|
PROMPTS.forEach((p, i) => {
|
|
body[p.key] = answers[i];
|
|
});
|
|
|
|
const respondRes = await fetch(
|
|
`/discovery/${conversationId}/respond`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}
|
|
);
|
|
if (!respondRes.ok) throw new Error("Could not save responses");
|
|
|
|
const completeRes = await fetch(
|
|
`/discovery/${conversationId}/complete`,
|
|
{ method: "POST" }
|
|
);
|
|
if (!completeRes.ok) {
|
|
const detail = await completeRes
|
|
.json()
|
|
.catch(() => ({ detail: "Extraction failed" }));
|
|
throw new Error(detail.detail || "Extraction failed");
|
|
}
|
|
|
|
window.location.href = `/static/profile.html?user_id=${encodeURIComponent(
|
|
userId
|
|
)}`;
|
|
} catch (err) {
|
|
el.loading.classList.remove("active");
|
|
el.flow.style.display = "block";
|
|
el.flow.innerHTML =
|
|
`<div class="error-box"><strong>Something went wrong.</strong><br/>` +
|
|
`${err.message}<br/><br/>Your answers are still here — ` +
|
|
`please try submitting again.</div>` +
|
|
`<div class="nav"><span></span>` +
|
|
`<button class="btn-primary" onclick="location.reload()">Reload</button></div>`;
|
|
}
|
|
}
|
|
|
|
// Kick off a conversation as soon as the page loads so the id is ready.
|
|
startConversation().catch(() => {
|
|
/* will retry on submit */
|
|
});
|
|
render();
|
|
</script>
|
|
</body>
|
|
</html>
|