Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copy this file to `.env` (which is git-ignored) before running `docker compose up`.
#
# These values intentionally match Precept.Api/appsettings.Development.json so a
# locally-run backend (`dotnet run`) connects to the containerized Postgres on
# localhost:5432 with no further configuration. Change them for production.

POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=precept_db

# Must be at least 32 bytes for HMAC-SHA256. Replace with a strong secret in production.
JWT_SECRET_KEY=dev-only-not-for-production-must-be-at-least-32-bytes-long-for-hmac-sha256

# Comma-separated host allow-list, or * for any. Restrict this in production.
ALLOWED_HOSTS=*
45 changes: 45 additions & 0 deletions Precept.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,45 @@ public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest r
});
}

// ─────────────────────────────────────────────────────────────
// DELETE /api/auth/account
// ─────────────────────────────────────────────────────────────

/// <summary>
/// Permanently deletes the authenticated user's account and ALL of their data.
/// Deleting the AspNetUsers row triggers ON DELETE CASCADE on every owned table
/// (stories, applications + events, job descriptions, skills, behavioral stories,
/// testimonials, refresh tokens, and the Identity claim/login/role/token tables),
/// so nothing is left behind in PostgreSQL. This is irreversible.
/// </summary>
[Authorize]
[HttpDelete("account")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> DeleteAccount()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
return Unauthorized();

var user = await userManager.FindByIdAsync(userId);
if (user == null)
return NotFound();

// Single DELETE FROM "AspNetUsers" — the database cascades to all dependent rows.
var result = await userManager.DeleteAsync(user);
if (!result.Succeeded)
{
logger.AccountDeletionFailed(userId);
return BadRequest(result.Errors);
}

// Refresh-token rows are gone via cascade; clear the client cookie too.
ClearRefreshCookie();
logger.AccountDeleted(userId);

return Ok(new { message = "Account and all associated data have been permanently deleted." });
}

/// <summary>
/// [Fail-Safe Identity-Wide Cascade Revocation]: Invalidates all active sessions for a user identity.
/// Design Scope Note: While our replay detection mechanism is strictly "Lineage-Aware" (using ReplacedByToken
Expand Down Expand Up @@ -573,4 +612,10 @@ public static partial class AuthControllerLoggerExtensions

[LoggerMessage(Level = LogLevel.Information, Message = "Refresh token revoked for user {UserId}")]
public static partial void TokenRevoked(this ILogger logger, string? userId);

[LoggerMessage(Level = LogLevel.Warning, Message = "Account permanently deleted for user {UserId}")]
public static partial void AccountDeleted(this ILogger logger, string? userId);

[LoggerMessage(Level = LogLevel.Error, Message = "Account deletion failed for user {UserId}")]
public static partial void AccountDeletionFailed(this ILogger logger, string? userId);
}
11 changes: 10 additions & 1 deletion Precept.Web/src/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};

// Permanently deletes the account and all server-side data. Only clears local
// session state once the backend confirms the deletion succeeded.
const deleteAccount = async () => {
await api.delete('/api/auth/account');
setIsAuthenticated(false);
setUser(null);
setAccessToken(null);
};

return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, user, login, register, updateProfile, logout }}>
<AuthContext.Provider value={{ isAuthenticated, isLoading, user, login, register, updateProfile, logout, deleteAccount }}>
{children}
</AuthContext.Provider>
);
Expand Down
20 changes: 10 additions & 10 deletions Precept.Web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,16 @@
--font-label-caps: "JetBrains Mono", monospace;
--font-code: "JetBrains Mono", monospace;

/* Spacings */
--spacing-md: 24px;
--spacing-xl: 96px;
--spacing-container-max: 1440px;
--spacing-sm: 12px;
--spacing-lg: 48px;
--spacing-margin: 64px;
--spacing-gutter: 24px;
--spacing-base: 8px;
--spacing-xs: 4px;
/* Design-system spacings (prefixed --space- to avoid Tailwind v4 collision) */
--space-xs: 4px;
--space-sm: 12px;
--space-base: 8px;
--space-md: 24px;
--space-lg: 48px;
--space-xl: 96px;
--space-margin: 64px;
--space-gutter: 24px;
--space-container-max: 1440px;
}

@layer base {
Expand Down
31 changes: 21 additions & 10 deletions Precept.Web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@ import App from './App.tsx';
import './index.css';

if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(
(registration) => {
console.log('Precept PWA ServiceWorker registered with scope:', registration.scope);
},
(err) => {
console.error('Precept PWA ServiceWorker registration failed:', err);
}
);
});
if (import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(
(registration) => {
console.log('Precept PWA ServiceWorker registered with scope:', registration.scope);
},
(err) => {
console.error('Precept PWA ServiceWorker registration failed:', err);
}
);
});
} else {
// In dev the SW caches Vite's hashed chunks and serves stale ones,
// breaking HMR and causing duplicate-React errors. Unregister and purge.
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => registration.unregister());
});
if (window.caches) {
caches.keys().then((keys) => keys.forEach((key) => caches.delete(key)));
}
}
}

createRoot(document.getElementById('root')!).render(
Expand Down
35 changes: 32 additions & 3 deletions Precept.Web/src/pages/AppTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import confetti from 'canvas-confetti';
import { getCompanyIcon } from '../lib/utils';
import { AnimatedSection } from '../components/animation/AnimatedSection';
import { CountUp } from '../components/animation/CountUp';
import { Plus, LayoutGrid, List, X, Trash2, Loader2 } from 'lucide-react';
import { Plus, LayoutGrid, List, X, Trash2, Loader2, Terminal } from 'lucide-react';

/* ─────── DESIGN TOKENS (Landing.tsx) ─────── */
const C = {
Expand Down Expand Up @@ -308,8 +308,36 @@ export default function AppTracker() {
<span className="font-mono text-sm">Loading pipeline…</span>
</div>
) : (
<div className="flex-1 overflow-auto opacity-0 animate-fade-in-up delay-200">
{view === 'board' ? (
<div
className="flex-1 rounded-2xl overflow-hidden flex flex-col opacity-0 animate-fade-in-up delay-200"
style={{
background: `linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%)`,
border: `1px solid ${C.hair2}`,
boxShadow: `0 40px 100px -30px rgba(139,92,246,0.15), inset 0 1px 0 rgba(255,255,255,0.06)`,
backdropFilter: "blur(20px)",
}}
>
{/* Window Chrome Header */}
<div className="flex items-center justify-between px-4 py-3 shrink-0" style={{ borderBottom: `1px solid ${C.hair}`, background: C.bg1 }}>
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ background: "#ff5f57" }} />
<span className="h-2.5 w-2.5 rounded-full" style={{ background: "#febc2e" }} />
<span className="h-2.5 w-2.5 rounded-full" style={{ background: "#28c840" }} />
</div>
<div
className="hidden sm:flex items-center gap-2 rounded-md px-3 py-1 font-mono text-[11px]"
style={{ background: C.bg2, color: C.inkDim, border: `1px solid ${C.hair}` }}
>
<Terminal size={12} style={{ color: C.violet }} /> precept · ~/career/pipeline
</div>
<div className="font-mono text-[11px] flex items-center gap-1.5" style={{ color: C.inkDim }}>
<span className="inline-block h-1.5 w-1.5 rounded-full animate-ping" style={{ background: C.emerald }} />
<span style={{ color: C.emerald }}>{apps.length} active items</span>
</div>
</div>

<div className="p-4 md:p-6 flex-1 overflow-auto" style={{ background: C.bg1 }}>
{view === 'board' ? (
<div className="flex flex-col lg:flex-row gap-4 h-full pb-4">
{COLUMNS.map((col) => {
const color = statusColor(col);
Expand Down Expand Up @@ -449,6 +477,7 @@ export default function AppTracker() {
</div>
</section>
)}
</div>
</div>
)}

Expand Down
Loading
Loading