Initial import

This commit is contained in:
Stein Helge Riise
2025-11-17 08:32:46 +01:00
commit ede31fbb7e
129 changed files with 9514 additions and 0 deletions
@@ -0,0 +1,34 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "minattest-app-host", "minattest-app-host.csproj", "{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|x64.ActiveCfg = Debug|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|x64.Build.0 = Debug|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|x86.ActiveCfg = Debug|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Debug|x86.Build.0 = Debug|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|Any CPU.Build.0 = Release|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|x64.ActiveCfg = Release|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|x64.Build.0 = Release|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|x86.ActiveCfg = Release|Any CPU
{DFBC912A-1AC9-4F87-812D-FCC4C619A4BB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
@@ -0,0 +1,16 @@
namespace minattest_app_host.OpenApi
{
public static class OpenApiExtensions
{
public static void AddSwagger(this IServiceCollection services)
{
services.AddSwaggerGen();
}
public static void UseAppSwagger(this WebApplication app)
{
app.UseSwagger();
app.UseSwaggerUI();
}
}
}
+56
View File
@@ -0,0 +1,56 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using minattest_app_host.OpenApi;
using Serilog;
using Yarp.ReverseProxy.Forwarder;
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
Log.Information("Starting minattest app host");
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services));
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwagger();
builder.Services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
var app = builder.Build();
app.UseSerilogRequestLogging();
app.Use((context, next) =>
{
context.Request.Scheme = "https";
return next(context);
});
app.UseAppSwagger();
app.UseHttpsRedirection();
app.MapReverseProxy(configure => configure.Use(async (context, next) =>
{
await next();
var errorFeature = context.GetForwarderErrorFeature();
if (errorFeature is not null && errorFeature.Error != ForwarderError.None && errorFeature.Exception != null)
throw errorFeature.Exception;
}));
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"MinAttestApp": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:10001;http://localhost:10000",
"dotnetRunMessages": true
}
}
}
+26
View File
@@ -0,0 +1,26 @@
minattest-app-host (BFF)
Overview
- .NET 9 minimal app acting as a reverse proxy for both the React frontend and the API during development.
- Uses YARP to forward:
- `/api/*` to the API (`API_URL`, default `https://localhost:7172`).
- everything else to the frontend dev server (`FRONTEND_URL`, default `http://localhost:5173`).
Configure
- Env vars:
- `FRONTEND_URL` (default: `http://localhost:5173`)
- `API_URL` (default: `https://localhost:7172`)
- `launchSettings.json` sets both for dev profiles.
Run
- Start your API (e.g., at `https://localhost:7172`).
- Start your React dev server (e.g., `npm run dev` on port 5173).
- From repo root: `dotnet run --project frontend/minattest-app-host`
- Open the BFF URL shown in the console (e.g., `https://localhost:10001`).
- Requests to `/api/...` go to your API.
- All other paths (including `/`) go to the React dev server.
Notes
- WebSockets are enabled to support HMR from Vite/CRA dev servers.
- If your API uses the ASP.NET Core dev certificate, ensure it's trusted: `dotnet dev-certs https --trust`.
- Later, add auth and API composition here while keeping the frontend origin hidden from browsers.
@@ -0,0 +1,42 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Verbose",
"Override": {
"Microsoft": "Information",
"System": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Literate, Serilog.Sinks.Console",
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}|{RequestId}] {Message:lj} <s:{SourceContext}>{NewLine}{Exception}"
}
}
],
"Enrich": [ "FromLogContext" ]
},
"ReverseProxy": {
"Routes": {
"userApiRoute": {
"ClusterId": "clusterUser",
"Match": {
"Path": "/api/{**catch-all}"
}
}
},
"Clusters": {
"clusterUser": {
"Destinations": {
"destination1": {
"Address": "https://localhost:7172/"
}
}
}
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,42 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console" ],
"MinimumLevel": {
"Default": "Verbose",
"Override": {
"Microsoft": "Information",
"System": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Literate, Serilog.Sinks.Console",
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}|{RequestId}] {Message:lj} <s:{SourceContext}>{NewLine}{Exception}"
}
}
],
"Enrich": [ "FromLogContext" ]
},
"ReverseProxy": {
"Routes": {
"userApiRoute": {
"ClusterId": "clusterUser",
"Match": {
"Path": "/api/{**catch-all}"
}
}
},
"Clusters": {
"clusterUser": {
"Destinations": {
"destination1": {
"Address": "https://localhost:7172/"
}
}
}
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>minattest_app_host</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="9.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
</ItemGroup>
</Project>
+43
View File
@@ -0,0 +1,43 @@
MinAttest React App (Vite)
Overview
- Vite + React app for MinAttest.
- Works with the .NET BFF at `frontend/minattest-app-host`.
- Calls the API through the BFF using relative `/api/...` paths.
Dev setup
- Start Api in `backend/minattest-app`
- Start BFF in `frontend/minattest-app-host`
- From `frontend/minattest-app`:
- Install deps: `npm ci` (or `npm install`)
- Start dev server: `npm run dev` (defaults to `http://localhost:5173`)
Login flow (no real auth)
- On the start page, choose one of:
- "Logg inn privat": enter a Person ID (GUID)
- "Logg inn bedrift": enter an Employer ID (GUID)
- The session is stored in localStorage and used until you click "Logg ut".
Available features
- Privat:
- Shows Person details
- Lists attester, with actions: Vis (inline preview), Last ned
- Upload PDF attest with title, period, summary
- Bedrift:
- Shows Employer details
- Lists attester, with actions: Vis (inline preview), Last ned
- Issue/upload PDF attest for a person with title, period, summary
Notes
- Deleting attester and toggling verification are present as UI controls but are not wired to API calls yet (endpoints missing). They currently show a notice.
- The app expects API routes:
- GET `/api/v1/persons/{id}` and `/api/v1/persons/{id}/attests`
- GET `/api/v1/employers/{id}` and `/api/v1/employers/{id}/attests`
- GET download endpoints under each entity: `.../attests/{attestId}/download`
- POST `/api/v1/persons/{id}/attests` with body matching `PersonAttestUploadRequest`
- POST `/api/v1/employers/{id}/attests` with body matching `EmployerAttestUploadRequest`
@@ -0,0 +1,52 @@
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
const nodeModulesPath = './node_modules';
const packageJsonPath = './package.json';
try {
if (!fs.existsSync(packageJsonPath)) {
console.error('package.json not found. Please make sure you are in a valid Node.js project.');
process.exit(1);
}
if (!fs.existsSync(nodeModulesPath)) {
console.log('node_modules not found. Please run "npm install" to install dependencies.');
execSync('npm install', { stdio: 'inherit' });
console.log('Dependencies installed successfully. You can now run "npm run dev".');
process.exit(0);
} else {
console.log('node_modules found. Checking if dependencies are installed...');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
const missingDependencies = [];
for (const depName of Object.keys(dependencies)) {
const depPath = path.join(nodeModulesPath, depName);
if (!fs.existsSync(depPath)) {
missingDependencies.push(depName);
}
}
if (missingDependencies.length === 0) {
console.log('All dependencies are installed.');
} else {
console.log('Missing one or more dependencies:');
missingDependencies.forEach((dep) => {
console.log(` - ${dep}`);
});
console.log('Manually run "npm install" to install the missing dependencies.');
process.exit(1);
}
}
} catch (error) {
console.error('Error checking dependencies:', error.message || error);
process.exit(1);
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MinAttest</title>
<script type="module" crossorigin src="/assets/index-DmY-Yyah.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B3SznAWe.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MinAttest</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "minattest-app",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5173"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "^5.6.2",
"vite": "^7.1.9"
}
}
@@ -0,0 +1,35 @@
param(
[string]$PfxPath = "$env:USERPROFILE\.aspnet\https\minattest-app.pfx",
[string]$PfxPass = "mypass",
[int]$BffHttpsPort = 7228,
[int]$Port = 5173
)
function Ensure-DevCertExported {
param([string]$Path, [string]$Pass)
if (-not (Test-Path -LiteralPath $Path)) {
Write-Host "Exporting .NET dev cert to: $Path" -ForegroundColor Cyan
dotnet dev-certs https -ep $Path -p $Pass | Out-Null
dotnet dev-certs https --trust | Out-Null
}
}
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Move to app root (one up from scripts folder)
Set-Location (Resolve-Path (Join-Path $PSScriptRoot '..'))
Ensure-DevCertExported -Path $PfxPath -Pass $PfxPass
$env:SSL_PFX_PATH = $PfxPath
$env:SSL_PFX_PASS = $PfxPass
$env:VITE_ASPNETCORE_HTTPS_PORT = "$BffHttpsPort"
Write-Host "Launching Vite over HTTPS:" -ForegroundColor Green
Write-Host " SSL_PFX_PATH = $PfxPath"
Write-Host " VITE_ASPNETCORE_HTTPS_PORT = $BffHttpsPort"
Write-Host " Port = $Port"
npm run dev -- --port $Port
+89
View File
@@ -0,0 +1,89 @@
import type { EmployerResponse, PersonResponse, Guid, AttestSummary } from './types'
async function apiGet<T>(path: string, signal?: AbortSignal): Promise<T> {
const res = await fetch(path, { headers: { 'Accept': 'application/json' }, signal })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`API GET ${path} failed: ${res.status} ${res.statusText} ${text}`)
}
return res.json() as Promise<T>
}
export const Api = {
getPerson: (id: Guid, signal?: AbortSignal) => apiGet<PersonResponse>(`/api/v1/persons/${id}`, signal),
listPersonAttests: (id: Guid, signal?: AbortSignal) => apiGet<AttestSummary[]>(`/api/v1/persons/${id}/attests`, signal),
getEmployer: (id: Guid, signal?: AbortSignal) => apiGet<EmployerResponse>(`/api/v1/employers/${id}`, signal),
listEmployerAttests: (id: Guid, signal?: AbortSignal) => apiGet<AttestSummary[]>(`/api/v1/employers/${id}/attests`, signal),
personAttestDownloadUrl: (personId: Guid, attestId: Guid) => `/api/v1/persons/${personId}/attests/${attestId}/download`,
employerAttestDownloadUrl: (employerId: Guid, attestId: Guid) => `/api/v1/employers/${employerId}/attests/${attestId}/download`,
personAttestPreviewUrl: (personId: Guid, attestId: Guid) => `/api/v1/persons/${personId}/attests/${attestId}/download?inline=true`,
employerAttestPreviewUrl: (employerId: Guid, attestId: Guid) => `/api/v1/employers/${employerId}/attests/${attestId}/download?inline=true`,
uploadPersonAttest: async (
personId: Guid,
req: {
title: string
from: string // YYYY-MM-DD
to: string // YYYY-MM-DD
summary?: string | null
contentBase64: string
contentType: string
}
): Promise<{ attestId: Guid }> => {
const body = {
title: req.title,
from: req.from,
to: req.to,
summary: req.summary ?? null,
blobPath: '',
blobHash: null,
contentBase64: req.contentBase64,
contentType: req.contentType,
}
const path = `/api/v1/persons/${personId}/attests`
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`API POST ${path} failed: ${res.status} ${res.statusText} ${text}`)
}
return res.json()
},
uploadEmployerAttest: async (
employerId: Guid,
req: {
personId: Guid
title: string
from: string // YYYY-MM-DD
to: string // YYYY-MM-DD
summary?: string | null
contentBase64: string
contentType: string
}
): Promise<{ attestId: Guid }> => {
const body = {
personId: req.personId,
title: req.title,
from: req.from,
to: req.to,
summary: req.summary ?? null,
blobPath: '',
blobHash: null,
contentBase64: req.contentBase64,
contentType: req.contentType,
}
const path = `/api/v1/employers/${employerId}/attests`
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`API POST ${path} failed: ${res.status} ${res.statusText} ${text}`)
}
return res.json()
},
}
+5
View File
@@ -0,0 +1,5 @@
import ReactDOM from 'react-dom/client'
import { App } from './ui/App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
+120
View File
@@ -0,0 +1,120 @@
:root {
--bg: #f7f7f8;
--panel: #ffffff;
--text: #111827;
--muted: #6b7280;
--brand: #c3002f; /* NAV red */
--border: #e5e7eb;
--success: #16a34a;
--danger: #dc2626;
--warning: #d97706;
}
html, body, #root { height: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 16px; }
.app-header {
position: sticky; top: 0; z-index: 10; background: var(--panel);
border-bottom: 1px solid var(--border);
}
.app-header-inner { display: flex; align-items: center; gap: 16px; height: 64px; }
.logo { display: flex; align-items: center; gap: 10px; }
.logo img { height: 36px; width: auto; display: block; }
.logo strong { font-size: 18px; color: var(--brand); letter-spacing: 0.2px; }
.role-toggle { display: inline-flex; background: #f1f5f9; border-radius: 9999px; padding: 4px; }
.role-toggle button { border: none; background: transparent; padding: 6px 12px; border-radius: 9999px; cursor: pointer; color: var(--muted); font-weight: 600; }
.role-toggle button[aria-pressed="true"] { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(16,24,40,0.06); }
.spacer { flex: 1; }
.login-area { display: flex; align-items: center; gap: 8px; }
.login-area input[type="text"] { height: 34px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px; min-width: 340px; }
.btn { height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--border); background: #fff; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.btn.primary { background: var(--brand); color: #fff; border-color: var(--brand); }
.btn.ghost { background: transparent; }
.btn.secondary { background: #fff; border-color: #cbd5e1; }
.btn.danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn.sm { height: 28px; padding: 0 10px; border-radius: 6px; font-size: 14px; }
.btn:disabled { opacity: .6; cursor: default; }
.btn .icon { width: 14px; height: 14px; margin-right: 6px; vertical-align: -2px; }
/* Icon-only buttons/links */
.icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); background: #fff; color: var(--text); cursor: pointer; text-decoration: none; }
.icon-btn:hover { background: #f3f4f6; }
.icon-btn .icon { width: 16px; height: 16px; margin: 0; }
.app-main { padding: 16px 0 40px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
.cards { margin-top: 8px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
.section { margin-bottom: 16px; }
.section-title { display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px; }
.toolbar { display: flex; align-items: center; gap: 8px; }
.tag { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 9999px; padding: 2px 10px; font-size: 12px; color: var(--muted); background: #f8fafc; }
.tag.success { color: var(--success); border-color: #bbf7d0; background: #ecfdf5; }
.tag.muted { color: var(--muted); }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-top: 8px; }
.kpi { background: #fafafa; border: 1px dashed var(--border); border-radius: 12px; padding: 12px; }
.kpi strong { display: block; font-size: 20px; }
.kpi span { color: var(--muted); font-size: 12px; }
.alert { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border); background: #fff7ed; color: #7c2d12; }
.alert.error { background: #fef2f2; color: #991b1b; }
.alert.success { background: #f0fdf4; color: #14532d; }
/* Modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(17,24,39,.5); display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 50; }
.modal { width: min(1100px, 96vw); background: var(--panel); border-radius: 12px; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; max-height: 92vh; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.modal-body { padding: 0; }
.modal-body iframe { display: block; width: 100%; height: 70vh; border: none; }
/* Forms */
.form-grid { display: grid; gap: 10px; grid-template-columns: 1fr; }
@media (min-width: 720px) { .form-grid.cols-2 { grid-template-columns: 1fr 1fr; } }
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.field label.btn { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 0; font-size: 14px; color: var(--text); }
.field input[type="text"], .field input[type="date"], .field textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; }
.help { color: var(--muted); font-size: 12px; }
/* Accessible visually hidden input (screen-reader only) */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.file-row { display: flex; align-items: center; gap: 8px; }
/* Loader */
.spinner { width: 16px; height: 16px; border: 2px solid #fff; border-top-color: rgba(255,255,255,.2); border-radius: 9999px; display: inline-block; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg) } }
/* Date picker */
.date-input { position: relative; display: inline-block; }
.date-field { width: 150px; height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; display: inline-flex; align-items: center; justify-content: space-between; cursor: pointer; }
.date-field:hover { background: #f9fafb; }
.date-field .value { color: var(--text); font-variant-numeric: tabular-nums; }
.date-field .placeholder { color: var(--muted); }
.date-field .icon { width: 16px; height: 16px; opacity: .7; }
.calendar-popover { position: absolute; top: calc(100% + 6px); left: 0; background: #fff; border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 10px 25px rgba(16,24,40,0.1); width: 280px; padding: 8px; z-index: 40; }
.cal-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 6px; }
.cal-title { font-weight: 600; text-transform: capitalize; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-top: 6px; }
.cal-cell { text-align: center; padding: 6px 0; font-size: 13px; color: var(--text); }
.cal-wd { color: var(--muted); text-transform: lowercase; }
.cal-day { border: 1px solid transparent; background: transparent; border-radius: 8px; cursor: pointer; }
.cal-day:hover { background: #f3f4f6; }
.cal-day.selected { background: #eef2ff; border-color: #c7d2fe; }
/* Inline pair for date fields */
.field-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; width: fit-content; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 600; }
.muted { color: var(--muted); }
.success { color: #16a34a; }
.error { color: #dc2626; }
+27
View File
@@ -0,0 +1,27 @@
export type Guid = string
export interface AttestSummary {
id: Guid
employer: string
title: string
from: string // DateOnly as ISO string
to: string // DateOnly as ISO string
verified: boolean
}
export interface PersonResponse {
id: Guid
nationalIdHash: string
email?: string | null
phone?: string | null
attests: AttestSummary[]
}
export interface EmployerResponse {
id: Guid
orgNumber: string
name: string
}
export type Role = 'privat' | 'bedrift'
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from 'react'
import { EmployerView } from './EmployerView'
import { PersonView } from './PersonView'
import { HomeLanding } from './HomeLanding'
import type { Guid, Role } from '../types'
import { isGuidLike } from '../utils'
import { Api } from '../api'
type Session = { role: Role; id: Guid } | null
function loadSession(): Session {
try {
const text = localStorage.getItem('minattest.session')
if (!text) return null
const obj = JSON.parse(text)
if ((obj.role === 'privat' || obj.role === 'bedrift') && typeof obj.id === 'string') return obj
} catch {
// ignore
}
return null
}
function saveSession(s: Session) {
if (s) localStorage.setItem('minattest.session', JSON.stringify(s))
else localStorage.removeItem('minattest.session')
}
export function App() {
const [session, setSession] = useState<Session>(() => loadSession())
const [selectedRole, setSelectedRole] = useState<Role>('privat')
const [pendingId, setPendingId] = useState('')
const [loginErr, setLoginErr] = useState<string | null>(null)
const [identity, setIdentity] = useState<string | null>(null)
const performLogin = () => {
setLoginErr(null)
if (!isGuidLike(pendingId)) { setLoginErr('Ugyldig GUID'); return }
const s = { role: selectedRole, id: pendingId as Guid }
setSession(s); saveSession(s)
}
const onLogout = () => {
setSession(null)
saveSession(null)
}
// Load identity label for header (email for privat, company name for bedrift)
useEffect(() => {
if (!session) { setIdentity(null); return }
const ac = new AbortController()
;(async () => {
try {
if (session.role === 'privat') {
const p = await Api.getPerson(session.id, ac.signal)
setIdentity(p.email ?? 'Ukjent epost')
} else {
const e = await Api.getEmployer(session.id, ac.signal)
setIdentity(e.name || 'Ukjent bedrift')
}
} catch {
setIdentity(session.role === 'privat' ? 'Ukjent epost' : 'Ukjent bedrift')
}
})()
return () => ac.abort()
}, [session])
const body = useMemo(() => {
if (!session) return <HomeLanding />
if (session.role === 'privat') return <PersonView personId={session.id} />
return <EmployerView employerId={session.id} />
}, [session])
return (
<div>
<div className="app-header">
<div className="container app-header-inner">
<div className="logo">
<strong>MinAttest</strong>
</div>
<div className="role-toggle" role="group" aria-label="Velg rolle">
<button aria-pressed={selectedRole === 'privat'} onClick={() => setSelectedRole('privat')}>Privat</button>
<button aria-pressed={selectedRole === 'bedrift'} onClick={() => setSelectedRole('bedrift')}>Bedrift</button>
</div>
<div className="spacer" />
<div className="login-area">
{!session ? (
<>
<input
type="text"
placeholder={selectedRole === 'privat' ? 'Person-ID (GUID)' : 'Arbeidsgiver-ID (GUID)'}
value={pendingId}
onChange={(e) => setPendingId(e.target.value)}
/>
<button className="btn primary" onClick={performLogin}>Logg inn</button>
{loginErr && <span className="error" style={{ marginLeft: 8 }}>{loginErr}</span>}
</>
) : (
<>
<span className="muted">Innlogget som: {identity ?? ''}</span>
<button className="btn" onClick={onLogout}>Logg ut</button>
</>
)}
</div>
</div>
</div>
<main className="container app-main">{body}</main>
</div>
)
}
@@ -0,0 +1,127 @@
import { useEffect, useMemo, useRef, useState } from 'react'
const nbMonths = ['januar','februar','mars','april','mai','juni','juli','august','september','oktober','november','desember']
const nbWeekdays = ['man','tir','ons','tor','fre','lør','søn']
function clampMonth(year: number, month: number) {
if (month < 0) { year -= 1; month = 11 }
if (month > 11) { year += 1; month = 0 }
return { year, month }
}
function parseDateOnly(value?: string): Date | null {
if (!value) return null
const m = value.match(/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/)
if (!m) return null
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
return isNaN(d.getTime()) ? null : d
}
function toDateOnly(d: Date): string {
const y = d.getFullYear(); const m = String(d.getMonth()+1).padStart(2,'0'); const day = String(d.getDate()).padStart(2,'0')
return `${y}-${m}-${day}`
}
export function DatePicker({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) {
const selected = parseDateOnly(value) ?? new Date()
const [open, setOpen] = useState(false)
const [viewYear, setViewYear] = useState(selected.getFullYear())
const [viewMonth, setViewMonth] = useState(selected.getMonth())
const wrapperRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (!wrapperRef.current) return
if (!wrapperRef.current.contains(e.target as Node)) setOpen(false)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKey)
}
}, [])
useEffect(() => {
// When value changes externally, sync calendar view
const d = parseDateOnly(value)
if (d) { setViewYear(d.getFullYear()); setViewMonth(d.getMonth()) }
}, [value])
const days = useMemo(() => {
const first = new Date(viewYear, viewMonth, 1)
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate()
const weekStartMondayIndex = (first.getDay() + 6) % 7 // 0=Mon ... 6=Sun
const cells: Array<{ d: number | null; date?: Date }> = []
for (let i=0;i<weekStartMondayIndex;i++) cells.push({ d: null })
for (let day=1; day<=daysInMonth; day++) {
const date = new Date(viewYear, viewMonth, day)
cells.push({ d: day, date })
}
// pad to full weeks (6 rows max for stability)
while (cells.length % 7 !== 0) cells.push({ d: null })
return cells
}, [viewYear, viewMonth])
const display = useMemo(() => {
const d = parseDateOnly(value)
if (!d) return '—'
const dd = String(d.getDate()).padStart(2,'0')
const mm = String(d.getMonth()+1).padStart(2,'0')
const yyyy = d.getFullYear()
return `${dd}.${mm}.${yyyy}`
}, [value])
return (
<div className="date-input" ref={wrapperRef}>
<button
type="button"
className="date-field"
disabled={disabled}
onClick={() => setOpen(v => !v)}
aria-haspopup="dialog"
aria-expanded={open}
aria-label="Velg dato"
>
<span className={value ? 'value' : 'placeholder'}>{value ? display : 'dd.mm.åååå'}</span>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<path d="M16 2v4M8 2v4M3 10h18"/>
</svg>
</button>
{open && (
<div className="calendar-popover" role="dialog" aria-label="Kalender">
<div className="cal-header">
<button className="icon-btn" onClick={() => { const {year, month} = clampMonth(viewYear, viewMonth - 1); setViewYear(year); setViewMonth(month) }} aria-label="Forrige måned" title="Forrige måned">
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<div className="cal-title">{nbMonths[viewMonth]} {viewYear}</div>
<button className="icon-btn" onClick={() => { const {year, month} = clampMonth(viewYear, viewMonth + 1); setViewYear(year); setViewMonth(month) }} aria-label="Neste måned" title="Neste måned">
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 6l6 6-6 6"/></svg>
</button>
</div>
<div className="cal-grid cal-weekdays">
{nbWeekdays.map((w) => <div key={w} className="cal-cell cal-wd">{w}</div>)}
</div>
<div className="cal-grid cal-days">
{days.map((c, i) => {
if (c.d === null) return <div key={i} className="cal-cell" />
const isSelected = value && c.date && toDateOnly(c.date) === value
return (
<button
type="button"
key={i}
className={"cal-cell cal-day" + (isSelected ? ' selected' : '')}
onClick={() => { onChange(toDateOnly(c.date!)); setOpen(false) }}
>{c.d}</button>
)
})}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,254 @@
import { useEffect, useMemo, useState } from 'react'
import { Api } from '../api'
import { fileToBase64, isAbortError, isPdfFile, toDateOnlyString, formatRange, isGuidLike, toFileName } from '../utils'
import { DatePicker } from './DatePicker'
import type { AttestSummary, EmployerResponse, Guid } from '../types'
export function EmployerView({ employerId }: { employerId: Guid }) {
const [employer, setEmployer] = useState<EmployerResponse | null>(null)
const [attests, setAttests] = useState<AttestSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<Guid | null>(null)
const [uploading, setUploading] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
useEffect(() => {
const ac = new AbortController()
setError(null)
Promise.all([
Api.getEmployer(employerId, ac.signal),
Api.listEmployerAttests(employerId, ac.signal)
]).then(([e, a]) => { setEmployer(e); setAttests(a) }).catch(err => {
if (isAbortError(err)) return
const msg = err instanceof Error ? err.message : String(err)
setError(msg)
})
return () => ac.abort()
}, [employerId])
const previewUrl = useMemo(() => preview ? Api.employerAttestPreviewUrl(employerId, preview) : null, [preview, employerId])
return (
<div>
<section className="panel section">
<div className="section-title">
<h2 style={{ margin: 0 }}>Bedrift</h2>
<div className="toolbar">
<button className="btn secondary sm" onClick={() => {
setError(null)
const ac = new AbortController()
Promise.all([
Api.getEmployer(employerId, ac.signal),
Api.listEmployerAttests(employerId, ac.signal)
])
.then(([e, a]) => { setEmployer(e); setAttests(a) })
.catch(e => { if (!isAbortError(e)) setError((e as Error).message) })
}}>Oppdater</button>
</div>
</div>
{!employer && !error && <p className="muted">Laster arbeidsgiver</p>}
{error && <p className="alert error">{error}</p>}
{employer && (
<div className="kpis">
<div className="kpi"><strong>{employer.name}</strong><span>Navn</span></div>
<div className="kpi"><strong>{employer.orgNumber}</strong><span>Organisasjonsnummer</span></div>
<div className="kpi"><strong>{attests?.length ?? 0}</strong><span>Attester</span></div>
</div>
)}
</section>
<section className="panel section" style={{ marginBottom: 16 }}>
<div className="section-title">
<h3 style={{ margin: 0 }}>Utsted attest (PDF)</h3>
</div>
<UploadEmployerAttestForm
disabled={uploading}
onSubmit={async (data) => {
setError(null); setSuccess(null); setUploading(true)
try {
if (!data.file || !isPdfFile(data.file)) throw new Error('Kun PDF er tillatt.')
const contentBase64 = await fileToBase64(data.file)
const resp = await Api.uploadEmployerAttest(employerId, {
personId: data.personId,
title: data.title,
from: toDateOnlyString(data.from),
to: toDateOnlyString(data.to),
summary: data.summary || null,
contentBase64,
contentType: 'application/pdf',
})
setSuccess('Attest utstedt.')
setPreview(resp.attestId)
const a = await Api.listEmployerAttests(employerId)
setAttests(a)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
} finally {
setUploading(false)
}
}}
/>
{success && <p className="alert success">{success}</p>}
</section>
<section className="panel section">
<div className="section-title">
<h3 style={{ margin: 0 }}>Bedriftens attester</h3>
<div className="toolbar">
<button className="btn secondary sm" onClick={async () => {
setError(null)
try { setAttests(await Api.listEmployerAttests(employerId)) } catch (e) { setError((e as Error).message) }
}}>Oppdater liste</button>
</div>
</div>
{!attests && !error && <p className="muted">Laster attester</p>}
{attests && attests.length === 0 && <p className="muted">Ingen attester.</p>}
{attests && attests.length > 0 && (
<table>
<thead>
<tr>
<th>Tittel</th>
<th>Arbeidsgiver</th>
<th>Periode</th>
<th>Status</th>
<th style={{ width: 300 }}>Handlinger</th>
</tr>
</thead>
<tbody>
{attests.map(a => (
<tr key={a.id}>
<td>{a.title}</td>
<td>{a.employer}</td>
<td>{formatRange(a.from, a.to)}</td>
<td>
<span className={a.verified ? 'tag success' : 'tag muted'}>
{a.verified ? 'Verifisert' : 'Ubekreftet'}
</span>
</td>
<td>
<div className="toolbar">
<button
className="icon-btn"
onClick={() => setPreview(a.id)}
aria-label={`Vis ${a.title}`}
title="Vis"
>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<a
className="icon-btn"
href={Api.employerAttestDownloadUrl(employerId, a.id)}
download={`attest-${toFileName(a.title)}-${a.from}-${a.to}.pdf`}
aria-label={`Last ned ${a.title}`}
title="Last ned PDF"
>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v10"/>
<path d="M8 9l4 4 4-4"/>
<path d="M4 21h16"/>
</svg>
</a>
<button className="btn sm danger" onClick={() => {
if (confirm('Er du sikker på at du vil slette denne attesten?')) alert('Sletting ikke implementert i API ennå.')
}}>Slett</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{previewUrl && (
<div className="modal-backdrop" onClick={() => setPreview(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<strong>Forhåndsvisning</strong>
<button className="btn" onClick={() => setPreview(null)}>Lukk</button>
</div>
<div className="modal-body">
<iframe src={previewUrl} title="Forhåndsvisning" />
</div>
</div>
</div>
)}
</div>
)
}
function UploadEmployerAttestForm({
onSubmit,
disabled,
}: {
disabled?: boolean
onSubmit: (data: { personId: Guid; title: string; from: string; to: string; summary?: string; file?: File | null }) => void
}) {
const [personId, setPersonId] = useState('')
const [title, setTitle] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [summary, setSummary] = useState('')
const [file, setFile] = useState<File | null>(null)
const [localError, setLocalError] = useState<string | null>(null)
const submit = (e: React.FormEvent) => {
e.preventDefault()
setLocalError(null)
if (!isGuidLike(personId)) return setLocalError('Ugyldig person GUID')
if (!title.trim()) return setLocalError('Tittel er påkrevd')
if (!from || !to) return setLocalError('Fra/til-dato er påkrevd')
if (!file) return setLocalError('Velg en PDF-fil')
if (!isPdfFile(file)) return setLocalError('Kun PDF er tillatt')
onSubmit({ personId: personId as Guid, title: title.trim(), from, to, summary: summary.trim() || undefined, file })
// reset
setPersonId(''); setTitle(''); setFrom(''); setTo(''); setSummary('')
const input = document.getElementById('employer-file') as HTMLInputElement | null
if (input) input.value = ''
setFile(null)
}
return (
<form onSubmit={submit} className="form-grid cols-2" style={{ maxWidth: 900 }}>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>PersonID (GUID)</label>
<input value={personId} onChange={e => setPersonId(e.target.value)} disabled={disabled} placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>Tittel</label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={disabled} />
</div>
<div className="field-row" style={{ gridColumn: '1 / -1' }}>
<div className="field">
<label>Fra</label>
<DatePicker value={from} onChange={setFrom} disabled={disabled} />
</div>
<div className="field">
<label>Til</label>
<DatePicker value={to} onChange={setTo} disabled={disabled} />
</div>
</div>
<div className="field">
<label>Sammendrag (valgfritt)</label>
<textarea value={summary} onChange={e => setSummary(e.target.value)} disabled={disabled} rows={5} />
</div>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>PDFfil</label>
<div className="file-row">
<input id="employer-file" className="sr-only" type="file" accept="application/pdf,.pdf" onChange={e => setFile(e.target.files?.[0] ?? null)} disabled={disabled} />
<label htmlFor="employer-file" className="btn secondary">Velg PDF</label>
<span className="muted">{file ? file.name : 'Ingen fil valgt'}</span>
</div>
<div className="help">Kun PDF er tillatt.</div>
</div>
<div className="toolbar" style={{ gridColumn: '1 / -1' }}>
<button className="btn primary" type="submit" disabled={disabled}>{disabled ? <span className="spinner" /> : 'Utsted'}</button>
{localError && <span className="alert error" style={{ padding: '6px 8px' }}>{localError}</span>}
</div>
</form>
)
}
@@ -0,0 +1,58 @@
export function HomeLanding() {
return (
<div className="panel" style={{ padding: 20 }}>
<h2 style={{ marginTop: 0 }}>Hva er MinAttest?</h2>
<p>
MinAttest er en digital plattform for utstedelse, lagring, deling og verifisering av
arbeidsattester. Målet er å erstatte papir/PDF-attester med en standardisert, sikker og
brukervennlig løsning som gir verdi både for arbeidsgivere, ansatte og rekrutterere.
</p>
<div className="cards" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', marginTop: 12 }}>
<div className="card" style={card}>
<h3 style={h3}>For privatpersoner</h3>
<ul>
<li>Trygg tilgang til attester hele livet</li>
<li>Se og last ned attester</li>
<li>Last opp tidligere attester (markeres som ikke verifisert)</li>
</ul>
</div>
<div className="card" style={card}>
<h3 style={h3}>For bedrifter</h3>
<ul>
<li>Standardisert og sikker attesthåndtering</li>
<li>Utsted attester til ansatte</li>
<li>Oversikt over bedriftens attester</li>
</ul>
</div>
<div className="card" style={card}>
<h3 style={h3}>For rekrutterere</h3>
<ul>
<li>Verifiser attester via delte lenker</li>
<li>Rask innsikt i arbeidshistorikk</li>
</ul>
</div>
</div>
<h2 style={{ marginTop: 24 }}>Hva kan løsningen gjøre i dag (MVP)?</h2>
<ul>
<li>Pålogging via enkel demo (BFF) uten ekte autentisering</li>
<li>Privatperson: se og laste opp attester, forhåndsvise og laste ned</li>
<li>Bedrift: utstede attester til en person, forhåndsvise og laste ned</li>
<li>API med struktur for videre sikkerhet og deling</li>
</ul>
<h2 style={{ marginTop: 24 }}>Kommer snart</h2>
<ul>
<li>Ekte autentisering: BankID (privat) og Entra ID (bedrift)</li>
<li>Deling med verifiseringslenker</li>
<li>Signering og malbasert attestgenerering</li>
<li>Varslinger og admin-dashboard</li>
</ul>
</div>
)
}
const card: React.CSSProperties = { background: '#fff', border: '1px solid #e5e7eb', borderRadius: 12, padding: 16 }
const h3: React.CSSProperties = { marginTop: 0, marginBottom: 8 }
@@ -0,0 +1,49 @@
import { useState } from 'react'
import type { Guid, Role } from '../types'
function isGuidLike(s: string): boolean {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(s)
}
export function LoginGate({ onLogin }: { onLogin: (role: Role, id: Guid) => void }) {
const [mode, setMode] = useState<Role | null>(null)
const [id, setId] = useState('')
const [error, setError] = useState<string | null>(null)
const startPrivat = () => { setMode('privat'); setId(''); setError(null) }
const startBedrift = () => { setMode('bedrift'); setId(''); setError(null) }
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!mode) return
if (!isGuidLike(id)) { setError('Ugyldig GUID'); return }
onLogin(mode, id)
}
return (
<div>
<p>Velg innlogging for å fortsette.</p>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<button onClick={startPrivat}>Logg inn privat</button>
<button onClick={startBedrift}>Logg inn bedrift</button>
</div>
{mode && (
<form onSubmit={submit} style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<label>
{mode === 'privat' ? 'Person-ID (GUID):' : 'Arbeidsgiver-ID (GUID):'}
<input
type="text"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
value={id}
onChange={(e) => setId(e.target.value.trim())}
style={{ marginLeft: 8, width: 360 }}
/>
</label>
<button type="submit">Fortsett</button>
{error && <span style={{ color: 'crimson', marginLeft: 8 }}>{error}</span>}
</form>
)}
</div>
)
}
@@ -0,0 +1,256 @@
import { useEffect, useMemo, useState } from 'react'
import { Api } from '../api'
import { isAbortError, fileToBase64, isPdfFile, toDateOnlyString, formatRange, toFileName } from '../utils'
import { DatePicker } from './DatePicker'
import type { AttestSummary, Guid, PersonResponse } from '../types'
export function PersonView({ personId }: { personId: Guid }) {
const [person, setPerson] = useState<PersonResponse | null>(null)
const [attests, setAttests] = useState<AttestSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [preview, setPreview] = useState<Guid | null>(null)
const [uploading, setUploading] = useState(false)
const [success, setSuccess] = useState<string | null>(null)
useEffect(() => {
const ac = new AbortController()
setError(null)
Promise.all([
Api.getPerson(personId, ac.signal),
Api.listPersonAttests(personId, ac.signal)
]).then(([p, a]) => { setPerson(p); setAttests(a) }).catch(err => {
if (isAbortError(err)) return
const msg = err instanceof Error ? err.message : String(err)
setError(msg)
})
return () => ac.abort()
}, [personId])
const previewUrl = useMemo(() => preview ? Api.personAttestPreviewUrl(personId, preview) : null, [preview, personId])
return (
<div>
<section className="panel section">
<div className="section-title">
<h2 style={{ margin: 0 }}>Din profil</h2>
<div className="toolbar">
<button
className="btn secondary sm"
onClick={() => {
setError(null)
const ac = new AbortController()
Promise.all([
Api.getPerson(personId, ac.signal),
Api.listPersonAttests(personId, ac.signal)
])
.then(([p, a]) => { setPerson(p); setAttests(a) })
.catch(e => { if (!isAbortError(e)) setError((e as Error).message) })
}}
>Oppdater</button>
</div>
</div>
{!person && !error && <p className="muted">Laster person</p>}
{error && <p className="alert error">{error}</p>}
{person && (
<>
<div className="kpis">
<div className="kpi"><strong>{person.attests.length}</strong><span>Attester</span></div>
<div className="kpi"><strong>{person.email || '—'}</strong><span>Epost</span></div>
<div className="kpi"><strong>{person.phone || '—'}</strong><span>Telefon</span></div>
</div>
{/* ID intentionally hidden from UI */}
</>
)}
</section>
<section className="panel section" style={{ marginBottom: 16 }}>
<div className="section-title">
<h3 style={{ margin: 0 }}>Last opp attest (PDF)</h3>
</div>
<UploadPersonAttestForm
disabled={uploading}
onSubmit={async (data) => {
setError(null); setSuccess(null); setUploading(true)
try {
if (!data.file || !isPdfFile(data.file)) throw new Error('Kun PDF er tillatt.')
const contentBase64 = await fileToBase64(data.file)
const resp = await Api.uploadPersonAttest(personId, {
title: data.title,
from: toDateOnlyString(data.from),
to: toDateOnlyString(data.to),
summary: data.summary || null,
contentBase64,
contentType: 'application/pdf',
})
setSuccess('Attest lastet opp.')
setPreview(resp.attestId)
const [p, a] = await Promise.all([
Api.getPerson(personId),
Api.listPersonAttests(personId)
])
setPerson(p); setAttests(a)
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
} finally {
setUploading(false)
}
}}
/>
{success && <p className="alert success">{success}</p>}
</section>
<section className="panel section">
<div className="section-title">
<h3 style={{ margin: 0 }}>Attester</h3>
<div className="toolbar">
<button className="btn secondary sm" onClick={async () => {
setError(null)
try { setAttests(await Api.listPersonAttests(personId)) } catch (e) { setError((e as Error).message) }
}}>Oppdater liste</button>
</div>
</div>
{!attests && !error && <p className="muted">Laster attester</p>}
{attests && attests.length === 0 && <p className="muted">Ingen attester.</p>}
{attests && attests.length > 0 && (
<table>
<thead>
<tr>
<th>Tittel</th>
<th>Arbeidsgiver</th>
<th>Periode</th>
<th>Status</th>
<th style={{ width: 300 }}>Handlinger</th>
</tr>
</thead>
<tbody>
{attests.map(a => (
<tr key={a.id}>
<td>{a.title}</td>
<td>{a.employer}</td>
<td>{formatRange(a.from, a.to)}</td>
<td>
<span className={a.verified ? 'tag success' : 'tag muted'}>
{a.verified ? 'Verifisert' : 'Ubekreftet'}
</span>
</td>
<td>
<div className="toolbar">
<button
className="icon-btn"
onClick={() => setPreview(a.id)}
aria-label={`Vis ${a.title}`}
title="Vis"
>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<a
className="icon-btn"
href={Api.personAttestDownloadUrl(personId, a.id)}
download={`attest-${toFileName(a.title)}-${a.from}-${a.to}.pdf`}
aria-label={`Last ned ${a.title}`}
title="Last ned PDF"
>
<svg className="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v10"/>
<path d="M8 9l4 4 4-4"/>
<path d="M4 21h16"/>
</svg>
</a>
<button className="btn sm danger" onClick={() => {
if (confirm('Er du sikker på at du vil slette denne attesten?')) alert('Sletting ikke implementert i API ennå.')
}}>Slett</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{previewUrl && (
<div className="modal-backdrop" onClick={() => setPreview(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<strong>Forhåndsvisning</strong>
<button className="btn" onClick={() => setPreview(null)}>Lukk</button>
</div>
<div className="modal-body">
<iframe src={previewUrl} title="Forhåndsvisning" />
</div>
</div>
</div>
)}
</div>
)
}
function UploadPersonAttestForm({
onSubmit,
disabled,
}: {
disabled?: boolean
onSubmit: (data: { title: string; from: string; to: string; summary?: string; file?: File | null }) => void
}) {
const [title, setTitle] = useState('')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [summary, setSummary] = useState('')
const [file, setFile] = useState<File | null>(null)
const [localError, setLocalError] = useState<string | null>(null)
const submit = (e: React.FormEvent) => {
e.preventDefault()
setLocalError(null)
if (!title.trim()) return setLocalError('Tittel er påkrevd')
if (!from || !to) return setLocalError('Fra/til-dato er påkrevd')
if (!file) return setLocalError('Velg en PDF-fil')
if (!isPdfFile(file)) return setLocalError('Kun PDF er tillatt')
onSubmit({ title: title.trim(), from, to, summary: summary.trim() || undefined, file })
// reset
setTitle(''); setFrom(''); setTo(''); setSummary('')
const input = document.getElementById('person-file') as HTMLInputElement | null
if (input) input.value = ''
setFile(null)
}
return (
<form onSubmit={submit} className="form-grid cols-2" style={{ maxWidth: 900 }}>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>Tittel</label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={disabled} />
</div>
<div className="field-row" style={{ gridColumn: '1 / -1' }}>
<div className="field">
<label>Fra</label>
<DatePicker value={from} onChange={setFrom} disabled={disabled} />
</div>
<div className="field">
<label>Til</label>
<DatePicker value={to} onChange={setTo} disabled={disabled} />
</div>
</div>
<div className="field">
<label>Sammendrag (valgfritt)</label>
<textarea value={summary} onChange={e => setSummary(e.target.value)} disabled={disabled} rows={5} />
</div>
<div className="field" style={{ gridColumn: '1 / -1' }}>
<label>PDFfil</label>
<div className="file-row">
<input id="person-file" className="sr-only" type="file" accept="application/pdf,.pdf" onChange={e => setFile(e.target.files?.[0] ?? null)} disabled={disabled} />
<label htmlFor="person-file" className="btn secondary">Velg PDF</label>
<span className="muted">{file ? file.name : 'Ingen fil valgt'}</span>
</div>
<div className="help">Kun PDF er tillatt.</div>
</div>
<div className="toolbar" style={{ gridColumn: '1 / -1' }}>
<button className="btn primary" type="submit" disabled={disabled}>{disabled ? <span className="spinner" /> : 'Last opp'}</button>
{localError && <span className="alert error" style={{ padding: '6px 8px' }}>{localError}</span>}
</div>
</form>
)
}
+60
View File
@@ -0,0 +1,60 @@
export function isAbortError(err: unknown): boolean {
if (!err) return false
// DOMException AbortError in browsers
if (typeof DOMException !== 'undefined' && err instanceof DOMException && err.name === 'AbortError') return true
// Fallback check
return typeof err === 'object' && 'name' in (err as any) && (err as any).name === 'AbortError'
}
export function isPdfFile(file: File): boolean {
if (file.type === 'application/pdf') return true
return file.name.toLowerCase().endsWith('.pdf')
}
export async function fileToBase64(file: File): Promise<string> {
// Read as DataURL, then strip the prefix
const dataUrl: string = await new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onerror = () => reject(fr.error)
fr.onload = () => resolve(String(fr.result))
fr.readAsDataURL(file)
})
const comma = dataUrl.indexOf(',')
return comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl
}
export function toDateOnlyString(d: string | Date): string {
const date = typeof d === 'string' ? new Date(d) : d
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function isGuidLike(s: string): boolean {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(s)
}
export function formatDateOnly(d: string): string {
// Expect YYYY-MM-DD; fallback to original string
if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(d)) return d
const [y, m, day] = d.split('-')
return `${day}.${m}.${y}`
}
export function formatRange(from: string, to: string): string {
return `${formatDateOnly(from)} ${formatDateOnly(to)}`
}
export function toFileName(s: string): string {
try {
return s
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
} catch {
return s.replace(/\W+/g, '-').toLowerCase()
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": "."
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1 @@
{"root":["./src/api.ts","./src/main.tsx","./src/types.ts","./src/utils.ts","./src/ui/app.tsx","./src/ui/datepicker.tsx","./src/ui/employerview.tsx","./src/ui/homelanding.tsx","./src/ui/logingate.tsx","./src/ui/personview.tsx"],"version":"5.9.2"}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const getProxyTarget = () => {
return 'https://localhost:10001';
};
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: getProxyTarget(),
changeOrigin: true,
secure: false
}
}
}
})