Merge branch 'dev'
This commit is contained in:
commit
5c9ef2300b
|
|
@ -141,6 +141,144 @@ const registry: CanvasToolDefinition[] = [
|
||||||
}),
|
}),
|
||||||
actionLabel: (args) => `Generating image: ${args.prompt.slice(0, 50)}${args.prompt.length > 50 ? "..." : ""}`,
|
actionLabel: (args) => `Generating image: ${args.prompt.slice(0, 50)}${args.prompt.length > 50 ? "..." : ""}`,
|
||||||
},
|
},
|
||||||
|
// ── Trip Planning Tools ──
|
||||||
|
{
|
||||||
|
declaration: {
|
||||||
|
name: "create_destination",
|
||||||
|
description: "Create a destination card for a trip location. Use when the user mentions a city, place, or stop on their trip.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
destName: { type: "string", description: "Name of the destination (city or place)" },
|
||||||
|
country: { type: "string", description: "Country name" },
|
||||||
|
lat: { type: "number", description: "Latitude coordinate" },
|
||||||
|
lng: { type: "number", description: "Longitude coordinate" },
|
||||||
|
arrivalDate: { type: "string", description: "Arrival date in YYYY-MM-DD format" },
|
||||||
|
departureDate: { type: "string", description: "Departure date in YYYY-MM-DD format" },
|
||||||
|
notes: { type: "string", description: "Additional notes about this destination" },
|
||||||
|
},
|
||||||
|
required: ["destName"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tagName: "folk-destination",
|
||||||
|
buildProps: (args) => ({
|
||||||
|
destName: args.destName,
|
||||||
|
...(args.country ? { country: args.country } : {}),
|
||||||
|
...(args.lat != null ? { lat: args.lat } : {}),
|
||||||
|
...(args.lng != null ? { lng: args.lng } : {}),
|
||||||
|
...(args.arrivalDate ? { arrivalDate: args.arrivalDate } : {}),
|
||||||
|
...(args.departureDate ? { departureDate: args.departureDate } : {}),
|
||||||
|
...(args.notes ? { notes: args.notes } : {}),
|
||||||
|
}),
|
||||||
|
actionLabel: (args) => `Created destination: ${args.destName}${args.country ? `, ${args.country}` : ""}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
declaration: {
|
||||||
|
name: "create_itinerary",
|
||||||
|
description: "Create an itinerary card with a list of activities/events organized by date. Use when planning a schedule or day-by-day plan.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tripTitle: { type: "string", description: "Title for the itinerary" },
|
||||||
|
itemsJson: { type: "string", description: 'JSON array of items. Each: {"id":"<uuid>","title":"...","date":"YYYY-MM-DD","startTime":"HH:MM","category":"ACTIVITY|TRANSPORT|MEAL|FREE_TIME|FLIGHT"}' },
|
||||||
|
},
|
||||||
|
required: ["tripTitle", "itemsJson"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tagName: "folk-itinerary",
|
||||||
|
buildProps: (args) => {
|
||||||
|
let items: any[] = [];
|
||||||
|
try { items = JSON.parse(args.itemsJson); } catch { items = []; }
|
||||||
|
return { tripTitle: args.tripTitle, items };
|
||||||
|
},
|
||||||
|
actionLabel: (args) => `Created itinerary: ${args.tripTitle}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
declaration: {
|
||||||
|
name: "create_booking",
|
||||||
|
description: "Create a booking card for a flight, hotel, transport, activity, or restaurant reservation.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
bookingType: {
|
||||||
|
type: "string",
|
||||||
|
description: "Type of booking",
|
||||||
|
enum: ["FLIGHT", "HOTEL", "CAR_RENTAL", "TRAIN", "BUS", "FERRY", "ACTIVITY", "RESTAURANT", "OTHER"],
|
||||||
|
},
|
||||||
|
provider: { type: "string", description: "Provider/company name (e.g. airline, hotel name)" },
|
||||||
|
cost: { type: "number", description: "Cost amount" },
|
||||||
|
currency: { type: "string", description: "ISO currency code (e.g. USD, EUR)" },
|
||||||
|
startDate: { type: "string", description: "Start/check-in date in YYYY-MM-DD format" },
|
||||||
|
endDate: { type: "string", description: "End/check-out date in YYYY-MM-DD format" },
|
||||||
|
bookingStatus: { type: "string", description: "Booking status", enum: ["PENDING", "CONFIRMED", "CANCELLED"] },
|
||||||
|
details: { type: "string", description: "Additional booking details or notes" },
|
||||||
|
},
|
||||||
|
required: ["bookingType", "provider"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tagName: "folk-booking",
|
||||||
|
buildProps: (args) => ({
|
||||||
|
bookingType: args.bookingType,
|
||||||
|
provider: args.provider,
|
||||||
|
...(args.cost != null ? { cost: args.cost } : {}),
|
||||||
|
...(args.currency ? { currency: args.currency } : {}),
|
||||||
|
...(args.startDate ? { startDate: args.startDate } : {}),
|
||||||
|
...(args.endDate ? { endDate: args.endDate } : {}),
|
||||||
|
...(args.bookingStatus ? { bookingStatus: args.bookingStatus } : {}),
|
||||||
|
...(args.details ? { details: args.details } : {}),
|
||||||
|
}),
|
||||||
|
actionLabel: (args) => `Created booking: ${args.bookingType} — ${args.provider}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
declaration: {
|
||||||
|
name: "create_budget",
|
||||||
|
description: "Create a budget tracker card with total budget and expense line items. Use when the user wants to track trip costs.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
budgetTotal: { type: "number", description: "Total budget amount" },
|
||||||
|
currency: { type: "string", description: "ISO currency code (e.g. USD, EUR)" },
|
||||||
|
expensesJson: { type: "string", description: 'JSON array of expenses. Each: {"id":"<uuid>","category":"TRANSPORT|ACCOMMODATION|FOOD|ACTIVITY|SHOPPING|OTHER","description":"...","amount":123,"date":"YYYY-MM-DD"}' },
|
||||||
|
},
|
||||||
|
required: ["budgetTotal"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tagName: "folk-budget",
|
||||||
|
buildProps: (args) => {
|
||||||
|
let expenses: any[] = [];
|
||||||
|
try { expenses = JSON.parse(args.expensesJson); } catch { expenses = []; }
|
||||||
|
return {
|
||||||
|
budgetTotal: args.budgetTotal,
|
||||||
|
...(args.currency ? { currency: args.currency } : {}),
|
||||||
|
expenses,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
actionLabel: (args) => `Created budget: ${args.currency || "USD"} ${args.budgetTotal}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
declaration: {
|
||||||
|
name: "create_packing_list",
|
||||||
|
description: "Create a packing list card with checkable items organized by category. Use when the user needs help with what to pack.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
itemsJson: { type: "string", description: 'JSON array of packing items. Each: {"id":"<uuid>","name":"...","category":"CLOTHING|FOOTWEAR|ELECTRONICS|GEAR|PERSONAL|DOCUMENTS|SAFETY|SUPPLIES","quantity":1,"packed":false}' },
|
||||||
|
},
|
||||||
|
required: ["itemsJson"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tagName: "folk-packing-list",
|
||||||
|
buildProps: (args) => {
|
||||||
|
let items: any[] = [];
|
||||||
|
try { items = JSON.parse(args.itemsJson); } catch { items = []; }
|
||||||
|
return { items };
|
||||||
|
},
|
||||||
|
actionLabel: (args) => {
|
||||||
|
let count = 0;
|
||||||
|
try { count = JSON.parse(args.itemsJson).length; } catch {}
|
||||||
|
return `Created packing list (${count} items)`;
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry];
|
export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry];
|
||||||
|
|
|
||||||
|
|
@ -386,6 +386,7 @@ export class FolkPrompt extends FolkShape {
|
||||||
#model = "gemini-flash";
|
#model = "gemini-flash";
|
||||||
#pendingImages: string[] = [];
|
#pendingImages: string[] = [];
|
||||||
#toolsEnabled = false;
|
#toolsEnabled = false;
|
||||||
|
#systemPrompt = "";
|
||||||
|
|
||||||
#messagesEl: HTMLElement | null = null;
|
#messagesEl: HTMLElement | null = null;
|
||||||
#promptInput: HTMLTextAreaElement | null = null;
|
#promptInput: HTMLTextAreaElement | null = null;
|
||||||
|
|
@ -399,6 +400,20 @@ export class FolkPrompt extends FolkShape {
|
||||||
return this.#messages;
|
return this.#messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get toolsEnabled() { return this.#toolsEnabled; }
|
||||||
|
set toolsEnabled(v: boolean) {
|
||||||
|
this.#toolsEnabled = v;
|
||||||
|
this.#toolsBtn?.classList.toggle("active", v);
|
||||||
|
if (this.#promptInput) {
|
||||||
|
this.#promptInput.placeholder = v
|
||||||
|
? "Ask me to create maps, notes, images, or embeds on the canvas..."
|
||||||
|
: "Type your message...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get systemPrompt() { return this.#systemPrompt; }
|
||||||
|
set systemPrompt(v: string) { this.#systemPrompt = v; }
|
||||||
|
|
||||||
override createRenderRoot() {
|
override createRenderRoot() {
|
||||||
const root = super.createRenderRoot();
|
const root = super.createRenderRoot();
|
||||||
|
|
||||||
|
|
@ -471,15 +486,15 @@ export class FolkPrompt extends FolkShape {
|
||||||
// Tools toggle
|
// Tools toggle
|
||||||
this.#toolsBtn?.addEventListener("click", (e) => {
|
this.#toolsBtn?.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.#toolsEnabled = !this.#toolsEnabled;
|
this.toolsEnabled = !this.#toolsEnabled;
|
||||||
this.#toolsBtn!.classList.toggle("active", this.#toolsEnabled);
|
|
||||||
if (this.#promptInput) {
|
|
||||||
this.#promptInput.placeholder = this.#toolsEnabled
|
|
||||||
? "Ask me to create maps, notes, images, or embeds on the canvas..."
|
|
||||||
: "Type your message...";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync initial state after DOM ready
|
||||||
|
this.#toolsBtn?.classList.toggle("active", this.#toolsEnabled);
|
||||||
|
if (this.#toolsEnabled && this.#promptInput) {
|
||||||
|
this.#promptInput.placeholder = "Ask me to create maps, notes, images, or embeds on the canvas...";
|
||||||
|
}
|
||||||
|
|
||||||
// Attach button
|
// Attach button
|
||||||
attachBtn?.addEventListener("click", (e) => {
|
attachBtn?.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -635,7 +650,7 @@ export class FolkPrompt extends FolkShape {
|
||||||
...(m.images?.length ? { images: m.images } : {}),
|
...(m.images?.length ? { images: m.images } : {}),
|
||||||
})),
|
})),
|
||||||
model: this.#model,
|
model: this.#model,
|
||||||
...(useTools ? { useTools: true } : {}),
|
...(useTools ? { useTools: true, systemPrompt: this.#systemPrompt || undefined } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -780,6 +795,8 @@ export class FolkPrompt extends FolkShape {
|
||||||
|
|
||||||
static override fromData(data: Record<string, any>): FolkPrompt {
|
static override fromData(data: Record<string, any>): FolkPrompt {
|
||||||
const shape = FolkShape.fromData(data) as FolkPrompt;
|
const shape = FolkShape.fromData(data) as FolkPrompt;
|
||||||
|
if (data.toolsEnabled) shape.toolsEnabled = true;
|
||||||
|
if (data.systemPrompt) shape.systemPrompt = data.systemPrompt;
|
||||||
return shape;
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -788,6 +805,8 @@ export class FolkPrompt extends FolkShape {
|
||||||
...super.toJSON(),
|
...super.toJSON(),
|
||||||
type: "folk-prompt",
|
type: "folk-prompt",
|
||||||
model: this.#model,
|
model: this.#model,
|
||||||
|
toolsEnabled: this.#toolsEnabled || undefined,
|
||||||
|
systemPrompt: this.#systemPrompt || undefined,
|
||||||
messages: this.messages.filter((m) => m.role !== "tool-action").map((msg) => ({
|
messages: this.messages.filter((m) => m.role !== "tool-action").map((msg) => ({
|
||||||
role: msg.role,
|
role: msg.role,
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
|
|
@ -799,5 +818,7 @@ export class FolkPrompt extends FolkShape {
|
||||||
|
|
||||||
override applyData(data: Record<string, any>): void {
|
override applyData(data: Record<string, any>): void {
|
||||||
super.applyData(data);
|
super.applyData(data);
|
||||||
|
if (data.toolsEnabled !== undefined) this.toolsEnabled = !!data.toolsEnabled;
|
||||||
|
if (data.systemPrompt !== undefined) this.systemPrompt = data.systemPrompt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,8 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: var(--rs-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
|
||||||
.rapp-nav__btn:hover { background: #0d9488; }
|
.rapp-nav__btn:hover { background: #0d9488; }
|
||||||
|
.rapp-nav__btn--ai { background: linear-gradient(135deg, #0ea5e9, #6366f1); }
|
||||||
|
.rapp-nav__btn--ai:hover { opacity: 0.9; background: linear-gradient(135deg, #0ea5e9, #6366f1); }
|
||||||
|
|
||||||
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
||||||
.trip-card {
|
.trip-card {
|
||||||
|
|
@ -504,6 +506,7 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
<span class="rapp-nav__title">My Trips</span>
|
<span class="rapp-nav__title">My Trips</span>
|
||||||
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
|
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
|
||||||
|
<button class="rapp-nav__btn rapp-nav__btn--ai" id="btn-plan-ai">✨ Plan with AI</button>
|
||||||
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
<button class="rapp-nav__btn" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||||
</div>
|
</div>
|
||||||
${this.trips.length > 0 ? `<div class="trip-grid">
|
${this.trips.length > 0 ? `<div class="trip-grid">
|
||||||
|
|
@ -546,6 +549,7 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""}
|
${this._history.canGoBack ? `<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>` : ""}
|
||||||
<span class="rapp-nav__title">${this.esc(t.title)}</span>
|
<span class="rapp-nav__title">${this.esc(t.title)}</span>
|
||||||
|
<button class="rapp-nav__btn rapp-nav__btn--ai" id="btn-plan-ai">✨ Plan with AI</button>
|
||||||
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
|
|
@ -678,6 +682,9 @@ class FolkTripsPlanner extends HTMLElement {
|
||||||
private attachListeners() {
|
private attachListeners() {
|
||||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
|
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
|
||||||
|
this.shadow.getElementById("btn-plan-ai")?.addEventListener("click", () => {
|
||||||
|
window.location.href = `/${this.space}/rspace#trip-planner`;
|
||||||
|
});
|
||||||
|
|
||||||
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
|
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
|
|
|
||||||
|
|
@ -2225,7 +2225,9 @@ for (const mod of getAllModules()) {
|
||||||
|| pathname.endsWith("/api/transak/config")
|
|| pathname.endsWith("/api/transak/config")
|
||||||
|| pathname.endsWith("/api/transak/webhook")
|
|| pathname.endsWith("/api/transak/webhook")
|
||||||
|| pathname.endsWith("/api/coinbase/webhook")
|
|| pathname.endsWith("/api/coinbase/webhook")
|
||||||
|| pathname.endsWith("/api/ramp/webhook");
|
|| pathname.endsWith("/api/ramp/webhook")
|
||||||
|
|| pathname.includes("/rcart/api/payments")
|
||||||
|
|| pathname.includes("/rcart/pay/");
|
||||||
|
|
||||||
if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) {
|
if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) {
|
||||||
const token = extractToken(c.req.raw.headers);
|
const token = extractToken(c.req.raw.headers);
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,7 @@ export async function getRecoveryRequest(requestId: string): Promise<StoredRecov
|
||||||
export async function getActiveRecoveryRequest(userId: string): Promise<StoredRecoveryRequest | null> {
|
export async function getActiveRecoveryRequest(userId: string): Promise<StoredRecoveryRequest | null> {
|
||||||
const rows = await sql`
|
const rows = await sql`
|
||||||
SELECT * FROM recovery_requests
|
SELECT * FROM recovery_requests
|
||||||
WHERE user_id = ${userId} AND status = 'pending' AND expires_at > NOW()
|
WHERE user_id = ${userId} AND status IN ('pending', 'approved') AND expires_at > NOW()
|
||||||
ORDER BY initiated_at DESC LIMIT 1
|
ORDER BY initiated_at DESC LIMIT 1
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
|
|
|
||||||
|
|
@ -2257,6 +2257,220 @@ app.get('/recover', (c) => {
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SOCIAL RECOVERY COMPLETION PAGE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /recover/social — page where user finalizes social recovery
|
||||||
|
* After guardians approve, user opens this to register a new passkey
|
||||||
|
*/
|
||||||
|
app.get('/recover/social', (c) => {
|
||||||
|
return c.html(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Social Recovery — rStack Identity</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
|
||||||
|
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 480px; width: 100%; text-align: center; }
|
||||||
|
.icon { font-size: 3rem; margin-bottom: 0.5rem; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||||
|
.sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
|
||||||
|
.status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; line-height: 1.5; }
|
||||||
|
.status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
|
||||||
|
.status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
|
||||||
|
.status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
|
||||||
|
.status.warning { background: rgba(234,179,8,0.1); color: #eab308; }
|
||||||
|
.progress-bar { width: 100%; height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; margin: 1rem 0; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: linear-gradient(90deg, #00d4ff, #7c3aed); border-radius: 4px; transition: width 0.5s; }
|
||||||
|
.approval-list { text-align: left; margin: 1rem 0; }
|
||||||
|
.approval-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.85rem; color: #94a3b8; }
|
||||||
|
.approval-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.dot.approved { background: #22c55e; }
|
||||||
|
.dot.pending { background: #64748b; }
|
||||||
|
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #22c55e, #16a34a); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 1rem; }
|
||||||
|
.btn:hover { transform: translateY(-2px); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
.link { color: #00d4ff; text-decoration: none; }
|
||||||
|
.link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">👥</div>
|
||||||
|
<h1>Social Recovery</h1>
|
||||||
|
<p class="sub">Your guardians are helping you recover your account</p>
|
||||||
|
|
||||||
|
<div id="status" class="status loading">Checking recovery status...</div>
|
||||||
|
<div id="progress-section" class="hidden">
|
||||||
|
<div class="progress-bar"><div id="progress-fill" class="progress-fill" style="width:0%"></div></div>
|
||||||
|
<div id="approval-list" class="approval-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="complete-btn" class="btn hidden" onclick="completeRecovery()">Complete Recovery</button>
|
||||||
|
|
||||||
|
<div id="register-section" class="hidden">
|
||||||
|
<button id="register-btn" class="btn" onclick="registerNewPasskey()">Add New Passkey</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="success-section" class="hidden">
|
||||||
|
<p style="color:#94a3b8;margin-top:1rem;">You can now <a class="link" href="/">sign in with your new passkey</a> on any r*.online app.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const requestId = params.get('id');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const progressSection = document.getElementById('progress-section');
|
||||||
|
const progressFill = document.getElementById('progress-fill');
|
||||||
|
const approvalList = document.getElementById('approval-list');
|
||||||
|
const completeBtn = document.getElementById('complete-btn');
|
||||||
|
const registerSection = document.getElementById('register-section');
|
||||||
|
const successSection = document.getElementById('success-section');
|
||||||
|
|
||||||
|
let sessionToken = null;
|
||||||
|
let userId = null;
|
||||||
|
let username = null;
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
async function checkStatus() {
|
||||||
|
if (!requestId) {
|
||||||
|
statusEl.className = 'status error';
|
||||||
|
statusEl.textContent = 'No recovery request ID. Use the link from your recovery email.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/recovery/social/' + encodeURIComponent(requestId) + '/status');
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
statusEl.className = 'status error';
|
||||||
|
statusEl.textContent = err.error || 'Recovery request not found.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const pct = Math.round((data.approvalCount / data.threshold) * 100);
|
||||||
|
progressFill.style.width = Math.min(pct, 100) + '%';
|
||||||
|
|
||||||
|
// Show approval dots
|
||||||
|
approvalList.innerHTML = data.approvals.map(a =>
|
||||||
|
'<div class="approval-item"><span class="dot ' + (a.approved ? 'approved' : 'pending') + '"></span>' +
|
||||||
|
'Guardian ' + a.guardianId.slice(0, 8) + '... — ' + (a.approved ? 'Approved' : 'Waiting') + '</div>'
|
||||||
|
).join('');
|
||||||
|
progressSection.classList.remove('hidden');
|
||||||
|
|
||||||
|
if (data.status === 'approved') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
statusEl.className = 'status success';
|
||||||
|
statusEl.textContent = 'Recovery approved! ' + data.approvalCount + '/' + data.threshold + ' guardians confirmed. Complete your recovery below.';
|
||||||
|
completeBtn.classList.remove('hidden');
|
||||||
|
} else if (data.status === 'completed') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
statusEl.className = 'status success';
|
||||||
|
statusEl.textContent = 'This recovery has already been completed.';
|
||||||
|
progressSection.classList.add('hidden');
|
||||||
|
} else if (data.status === 'expired') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
statusEl.className = 'status error';
|
||||||
|
statusEl.textContent = 'This recovery request has expired. Please start a new one.';
|
||||||
|
} else {
|
||||||
|
// pending
|
||||||
|
statusEl.className = 'status loading';
|
||||||
|
statusEl.textContent = 'Waiting for guardian approvals... ' + data.approvalCount + '/' + data.threshold + ' so far. This page auto-refreshes.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.className = 'status error';
|
||||||
|
statusEl.textContent = 'Failed to check status: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeRecovery() {
|
||||||
|
completeBtn.disabled = true;
|
||||||
|
completeBtn.textContent = 'Completing...';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/recovery/social/' + encodeURIComponent(requestId) + '/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Failed to complete recovery');
|
||||||
|
}
|
||||||
|
sessionToken = data.token;
|
||||||
|
userId = data.userId;
|
||||||
|
username = data.username;
|
||||||
|
|
||||||
|
statusEl.className = 'status success';
|
||||||
|
statusEl.textContent = 'Recovery verified! Now register a new passkey for your account.';
|
||||||
|
completeBtn.classList.add('hidden');
|
||||||
|
registerSection.classList.remove('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.className = 'status error';
|
||||||
|
statusEl.textContent = 'Failed: ' + e.message;
|
||||||
|
completeBtn.disabled = false;
|
||||||
|
completeBtn.textContent = 'Complete Recovery';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerNewPasskey() {
|
||||||
|
const btn = document.getElementById('register-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Registering...';
|
||||||
|
try {
|
||||||
|
const startRes = await fetch('/api/register/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + sessionToken },
|
||||||
|
body: JSON.stringify({ username, displayName: username }),
|
||||||
|
});
|
||||||
|
const { options } = await startRes.json();
|
||||||
|
|
||||||
|
options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
|
||||||
|
options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
const credential = await navigator.credentials.create({ publicKey: options });
|
||||||
|
const credentialData = {
|
||||||
|
credentialId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
|
||||||
|
publicKey: btoa(String.fromCharCode(...new Uint8Array(credential.response.getPublicKey?.() || new ArrayBuffer(0)))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
|
||||||
|
transports: credential.response.getTransports?.() || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeRes = await fetch('/api/register/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }),
|
||||||
|
});
|
||||||
|
const result = await completeRes.json();
|
||||||
|
if (result.success) {
|
||||||
|
statusEl.className = 'status success';
|
||||||
|
statusEl.textContent = 'New passkey registered successfully!';
|
||||||
|
registerSection.classList.add('hidden');
|
||||||
|
successSection.classList.remove('hidden');
|
||||||
|
progressSection.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Registration failed');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.className = 'status error';
|
||||||
|
statusEl.textContent = 'Failed to register passkey: ' + e.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Add New Passkey';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check + poll every 10s while pending
|
||||||
|
checkStatus();
|
||||||
|
pollTimer = setInterval(checkStatus, 10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// GUARDIAN MANAGEMENT ROUTES
|
// GUARDIAN MANAGEMENT ROUTES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -2757,7 +2971,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
|
||||||
// Check for existing active request
|
// Check for existing active request
|
||||||
const existing = await getActiveRecoveryRequest(user.id);
|
const existing = await getActiveRecoveryRequest(user.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return c.json({ success: true, message: 'A recovery request is already active. Recovery emails have been re-sent.' });
|
return c.json({ success: true, requestId: existing.id, message: 'A recovery request is already active. Recovery emails have been re-sent.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create recovery request (7 day expiry, 2-of-3 threshold)
|
// Create recovery request (7 day expiry, 2-of-3 threshold)
|
||||||
|
|
@ -2773,6 +2987,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
|
||||||
title: 'Account recovery initiated',
|
title: 'Account recovery initiated',
|
||||||
body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`,
|
body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`,
|
||||||
metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length },
|
metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length },
|
||||||
|
actionUrl: `/recover/social?id=${requestId}`,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
// Create approval tokens and notify guardians
|
// Create approval tokens and notify guardians
|
||||||
|
|
@ -2854,7 +3069,7 @@ app.post('/api/recovery/social/initiate', async (c) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' });
|
return c.json({ success: true, requestId, message: 'If the account exists and has guardians, recovery emails have been sent.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -2973,6 +3188,7 @@ app.post('/api/recovery/social/approve', async (c) => {
|
||||||
title: 'Account recovery approved',
|
title: 'Account recovery approved',
|
||||||
body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`,
|
body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`,
|
||||||
metadata: { recoveryRequestId: request.id },
|
metadata: { recoveryRequestId: request.id },
|
||||||
|
actionUrl: `https://auth.rspace.online/recover/social?id=${request.id}`,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7394,7 +7610,10 @@ app.get('/', (c) => {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
msgEl.textContent = data.message || 'Recovery request sent. Check with your guardians.';
|
msgEl.innerHTML = data.message || 'Recovery request sent. Check with your guardians.';
|
||||||
|
if (data.requestId) {
|
||||||
|
msgEl.innerHTML += '<br><br><a href="/recover/social?id=' + encodeURIComponent(data.requestId) + '" style="color:#00d4ff;">Track your recovery progress →</a>';
|
||||||
|
}
|
||||||
msgEl.style.color = '#86efac';
|
msgEl.style.color = '#86efac';
|
||||||
msgEl.style.display = 'block';
|
msgEl.style.display = 'block';
|
||||||
// Also try email recovery
|
// Also try email recovery
|
||||||
|
|
|
||||||
|
|
@ -3642,6 +3642,33 @@
|
||||||
if (hash.startsWith("#pin-")) {
|
if (hash.startsWith("#pin-")) {
|
||||||
const pinId = hash.slice(5);
|
const pinId = hash.slice(5);
|
||||||
setTimeout(() => pinManager.openPinById(pinId), 500);
|
setTimeout(() => pinManager.openPinById(pinId), 500);
|
||||||
|
} else if (hash === "#trip-planner") {
|
||||||
|
history.replaceState(null, "", location.pathname + location.search);
|
||||||
|
setTimeout(() => {
|
||||||
|
const TRIP_SYSTEM_PROMPT = `You are a travel planning AI in rSpace. Help plan trips by creating visual shapes on the canvas.
|
||||||
|
|
||||||
|
When the user describes a trip, proactively create:
|
||||||
|
- Destination cards for each city/place (with coordinates and dates)
|
||||||
|
- A map showing the destinations
|
||||||
|
- Flight/accommodation search embeds (Google Flights, Airbnb, Booking.com)
|
||||||
|
- An itinerary with activities by date
|
||||||
|
- A budget tracker with estimated costs
|
||||||
|
- A packing list tailored to the destination
|
||||||
|
|
||||||
|
Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying questions if needed before creating shapes.`;
|
||||||
|
|
||||||
|
const prompt = newShape("folk-prompt", {
|
||||||
|
toolsEnabled: true,
|
||||||
|
systemPrompt: TRIP_SYSTEM_PROMPT,
|
||||||
|
});
|
||||||
|
if (prompt) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const root = prompt.shadowRoot || prompt;
|
||||||
|
const ta = root.querySelector(".prompt-input");
|
||||||
|
if (ta) ta.focus();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue