Add new Radix UI components and associated dependencies in package-lock.json
This commit is contained in:
@@ -4,6 +4,7 @@ tmp_dir = "tmp"
|
||||
[build]
|
||||
bin = "./tmp/gateway"
|
||||
cmd = "go build -o ./tmp/gateway ./cmd/gateway/main.go"
|
||||
full_bin = "DEV_MODE=true NATS_URL=nats://localhost:4222 ./tmp/gateway"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "web"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
|
||||
@@ -9,6 +9,9 @@ TIMESCALE_URL=postgres://admin:password@timescaledb:5432/telemetry_db?sslmode=di
|
||||
GATEWAY_URL=ws://gateway:8080/ws?role=worker
|
||||
GATEWAY_PUBLIC_URL=http://localhost:8080
|
||||
|
||||
# Dev Mode (in-memory store + auto-mock on first server added)
|
||||
DEV_MODE=true
|
||||
|
||||
# Worker Configuration
|
||||
MOCK_MODE=true
|
||||
LOG_FILE_PATH=arma_server.rpt
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
- Managed Trust Vault (Provider entschlüsselt temporär im RAM)
|
||||
- Event-zu-Discord-Mapping (Grundstruktur)
|
||||
|
||||
### Cryptography & Security
|
||||
- ✅ **Player Roster & Ban List (Zero Trust)**
|
||||
- ✅ Blind Index Generation in Worker & Dashboard
|
||||
- ✅ Encrypted storage of player/ban details
|
||||
- ✅ Searchable via Blind Index (HMAC)
|
||||
- ✅ Decryption on-the-fly in Dashboard
|
||||
|
||||
### Cryptography & Security
|
||||
- ✅ **Crypto Package** (`internal/crypto/crypto.go`)
|
||||
- AES-256-GCM Encryption/Decryption
|
||||
@@ -64,6 +71,12 @@
|
||||
- Telemetrie-Dashboard (FPS, Player Count, Latency)
|
||||
- DSGVO 1-Click Export
|
||||
|
||||
- ✅ **Zero Trust Player/Ban Management** (`web/dashboard/src/components/Players.tsx`)
|
||||
- Client-seitige Verschlüsselung von Bann-Gründen
|
||||
- Client-seitige Blind-Index Generierung
|
||||
- On-the-fly Entschlüsselung der Player-Liste
|
||||
- Sicherer Kick/Ban Flow ohne Key-Exposition
|
||||
|
||||
- ✅ **WebAuthn Login** (`web/dashboard/src/components/Login.tsx`)
|
||||
- Passwortloser Hardware-Login (FaceID, YubiKey, Windows Hello)
|
||||
- Browser-Kompatibilitätsprüfung
|
||||
|
||||
1864
cmd/gateway/main.go
1864
cmd/gateway/main.go
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/url"
|
||||
@@ -15,6 +16,51 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type DevServer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
EncryptedRcon string `json:"encryptedRcon"`
|
||||
}
|
||||
|
||||
type DecryptedRcon struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
Pass string `json:"pass"`
|
||||
}
|
||||
|
||||
func handleServerConfig(server DevServer, masterKey []byte) {
|
||||
if server.EncryptedRcon == "" {
|
||||
log.Printf("[RCON] Server %s has no RCON configuration", server.Name)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Decode Base64
|
||||
encryptedBytes, err := base64.StdEncoding.DecodeString(server.EncryptedRcon)
|
||||
if err != nil {
|
||||
log.Printf("[RCON] Failed to decode RCON blob for %s: %v", server.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Decrypt using local Master Key (E2EE)
|
||||
decryptedJSON, err := crypto.Decrypt(encryptedBytes, masterKey)
|
||||
if err != nil {
|
||||
log.Printf("[RCON] Failed to decrypt RCON for %s: %v (Key mismatch?)", server.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Parse RCON parameters
|
||||
var rcon DecryptedRcon
|
||||
if err := json.Unmarshal(decryptedJSON, &rcon); err != nil {
|
||||
log.Printf("[RCON] Failed to parse decrypted RCON JSON for %s: %v", server.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[RCON] Successfully decrypted credentials for %s", server.Name)
|
||||
log.Printf("[RCON] Target established: %s:%d (Auth secret verified)", rcon.Address, rcon.Port)
|
||||
|
||||
// TODO: Establish persistent RCON connection
|
||||
}
|
||||
|
||||
var mockLogs = []string{
|
||||
"12:30:01.122 SCRIPT : [RJSSupport][Chat] [Global] Zauberklöte: hi, leute kurze frage. zock seit monaten wieder mal arma, was ist aus dem gtg#4 und #5 geworden, da ist ja nix los",
|
||||
"09:37:50.865 DEFAULT : BattlEye Server: 'Player #0 Mike1Delta (92.209.175.19:6679) connected'",
|
||||
@@ -81,6 +127,25 @@ func main() {
|
||||
log.Println("Connected to gateway")
|
||||
var mu sync.Mutex
|
||||
|
||||
// 0. Listen for Configuration Updates (E2EE)
|
||||
go func() {
|
||||
for {
|
||||
_, message, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msg struct {
|
||||
Type string `json:"type"`
|
||||
Server DevServer `json:"server"`
|
||||
}
|
||||
if err := json.Unmarshal(message, &msg); err == nil && msg.Type == "CONFIG_UPDATE" {
|
||||
log.Printf("[CONFIG] Received update for server: %s", msg.Server.Name)
|
||||
handleServerConfig(msg.Server, masterKey)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 1. Telemetry Loop
|
||||
go func() {
|
||||
for {
|
||||
@@ -111,13 +176,14 @@ func main() {
|
||||
line := mockLogs[i%len(mockLogs)]
|
||||
event := parser.ParseLine(line)
|
||||
if event != nil {
|
||||
// Enrich event with blind index for player names
|
||||
if event.Type == "JOIN" || event.Type == "LEAVE" || event.Type == "CHAT" {
|
||||
playerName := extractPlayerName(event.Content)
|
||||
if playerName != "" {
|
||||
blindIndex := crypto.GenerateBlindIndex(playerName, masterKey)
|
||||
event.Content = event.Content + " [BLIND:" + blindIndex + "]"
|
||||
}
|
||||
event.ServerID = "server-123"
|
||||
event.ServerName = "AMS-NODE-01"
|
||||
|
||||
if event.PlayerName != "" {
|
||||
event.PlayerNameHash = crypto.GenerateBlindIndex(event.PlayerName, masterKey)
|
||||
} else {
|
||||
event.PlayerName = "SYSTEM"
|
||||
event.PlayerNameHash = "system-blind-index"
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(event)
|
||||
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
environment:
|
||||
- NATS_URL=nats://nats:4222
|
||||
- DB_URL=postgres://admin:password@postgres-master:5432/master_db?sslmode=disable
|
||||
- DEV_MODE=true
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
|
||||
@@ -7,10 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type LogEvent struct {
|
||||
Timestamp time.Time
|
||||
Type string // 'CHAT', 'JOIN', 'LEAVE', 'ADMIN', 'GENERIC'
|
||||
Content string
|
||||
Raw string
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type string `json:"type"` // 'CHAT', 'JOIN', 'LEAVE', 'ADMIN', 'GENERIC'
|
||||
Content string `json:"content"`
|
||||
PlayerName string `json:"playerName,omitempty"`
|
||||
PlayerNameHash string `json:"playerNameHash,omitempty"`
|
||||
ServerID string `json:"serverId,omitempty"`
|
||||
ServerName string `json:"serverName,omitempty"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
// Reforger-specific regex patterns
|
||||
@@ -37,6 +41,7 @@ func ParseLine(line string) *LogEvent {
|
||||
// Try Chat
|
||||
if matches := chatRegex.FindStringSubmatch(line); matches != nil {
|
||||
event.Type = "CHAT"
|
||||
event.PlayerName = matches[2]
|
||||
event.Content = matches[2] + ": " + matches[3]
|
||||
return event
|
||||
}
|
||||
@@ -44,6 +49,7 @@ func ParseLine(line string) *LogEvent {
|
||||
// Try Join
|
||||
if matches := joinRegex.FindStringSubmatch(line); matches != nil {
|
||||
event.Type = "JOIN"
|
||||
event.PlayerName = matches[2]
|
||||
event.Content = matches[2] + " connected to server"
|
||||
return event
|
||||
}
|
||||
@@ -51,6 +57,7 @@ func ParseLine(line string) *LogEvent {
|
||||
// Try Leave
|
||||
if matches := leaveRegex.FindStringSubmatch(line); matches != nil {
|
||||
event.Type = "LEAVE"
|
||||
event.PlayerName = matches[2]
|
||||
event.Content = matches[2] + " left the server"
|
||||
return event
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||
BIN
tmp/gateway
BIN
tmp/gateway
Binary file not shown.
BIN
tmp/worker
BIN
tmp/worker
Binary file not shown.
1795
web/dashboard/package-lock.json
generated
1795
web/dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ export const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
className="w-full px-4 py-3 bg-background border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -122,11 +122,11 @@ export const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-800">
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div className="p-4 bg-slate-900/50 rounded-xl">
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="text-2xl font-black text-indigo-400">0</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-1">Passwords</div>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-900/50 rounded-xl">
|
||||
<div className="p-4 bg-background/50 rounded-xl">
|
||||
<div className="text-2xl font-black text-green-400">100%</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-1">E2EE</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Fingerprint, Shield, Zap, AlertCircle, Loader2, Key } from 'lucide-react';
|
||||
import { Fingerprint, Shield, Zap, AlertCircle, Loader2, Key, CheckCircle, ArrowRight } from 'lucide-react';
|
||||
import { isWebAuthnSupported } from '../lib/webauthn';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string) => void;
|
||||
onLoginSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void;
|
||||
onSwitchToRegister: () => void;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [supported] = useState(isWebAuthnSupported());
|
||||
|
||||
// Stored after a successful password login while we show the passkey upsell
|
||||
const [pendingLogin, setPendingLogin] = useState<{
|
||||
token: string;
|
||||
masterKeyBytes: Uint8Array;
|
||||
communityId: string;
|
||||
username: string;
|
||||
userId: string;
|
||||
role?: string;
|
||||
} | null>(null);
|
||||
const [passkeySetupLoading, setPasskeySetupLoading] = useState(false);
|
||||
const [passkeySetupError, setPasskeySetupError] = useState<string | null>(null);
|
||||
|
||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -35,10 +47,14 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
const data = await res.json();
|
||||
const masterKeyBytes = new TextEncoder().encode(data.masterKey);
|
||||
|
||||
// Store token in localStorage for session persistence
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
|
||||
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username);
|
||||
if (supported) {
|
||||
// Show passkey upsell before entering the dashboard
|
||||
setPendingLogin({ token: data.token, masterKeyBytes, communityId: data.communityId, username: data.username, userId: data.userId, role: data.role });
|
||||
} else {
|
||||
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId, data.role);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
@@ -69,7 +85,7 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
timeout: options.timeout,
|
||||
rpId: options.rpId,
|
||||
allowCredentials: options.allowCredentials.map((id: string) => ({
|
||||
allowCredentials: (options.allowCredentials ?? []).map((id: string) => ({
|
||||
type: 'public-key' as const,
|
||||
id: base64urlToBuffer(id),
|
||||
})),
|
||||
@@ -105,7 +121,7 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
|
||||
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username);
|
||||
onLoginSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId ?? 'user-demo', data.role);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
@@ -133,18 +149,167 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
};
|
||||
|
||||
const handleSetupPasskey = async () => {
|
||||
if (!pendingLogin) return;
|
||||
setPasskeySetupLoading(true);
|
||||
setPasskeySetupError(null);
|
||||
try {
|
||||
const beginRes = await fetch('/api/auth/passkeys/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${pendingLogin.token}` },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!beginRes.ok) throw new Error('Could not start passkey setup');
|
||||
const options = await beginRes.json();
|
||||
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: base64urlToBuffer(options.challenge),
|
||||
rp: options.rp,
|
||||
user: {
|
||||
id: new TextEncoder().encode(options.user.id),
|
||||
name: options.user.name,
|
||||
displayName: options.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: options.pubKeyCredParams as PublicKeyCredentialParameters[],
|
||||
timeout: options.timeout,
|
||||
attestation: options.attestation as AttestationConveyancePreference,
|
||||
authenticatorSelection: options.authenticatorSelection as AuthenticatorSelectionCriteria,
|
||||
},
|
||||
}) as PublicKeyCredential | null;
|
||||
|
||||
if (!credential) throw new Error('Passkey setup was cancelled');
|
||||
const response = credential.response as AuthenticatorAttestationResponse;
|
||||
|
||||
const finishRes = await fetch('/api/auth/passkeys/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${pendingLogin.token}` },
|
||||
body: JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferToBase64url(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64url(response.clientDataJSON),
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!finishRes.ok) throw new Error('Failed to save passkey');
|
||||
|
||||
onLoginSuccess(pendingLogin.token, pendingLogin.masterKeyBytes, pendingLogin.communityId, pendingLogin.username, pendingLogin.userId, pendingLogin.role);
|
||||
} catch (err) {
|
||||
setPasskeySetupError((err as Error).message);
|
||||
setPasskeySetupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipPasskey = () => {
|
||||
if (!pendingLogin) return;
|
||||
onLoginSuccess(pendingLogin.token, pendingLogin.masterKeyBytes, pendingLogin.communityId, pendingLogin.username, pendingLogin.userId, pendingLogin.role);
|
||||
};
|
||||
|
||||
if (pendingLogin) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-6 relative overflow-hidden">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-48 w-96 h-96 bg-primary/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 -right-48 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full relative z-10">
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl mb-4 shadow-2xl shadow-primary/50">
|
||||
<Zap className="w-8 h-8 text-primary-foreground fill-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-foreground tracking-tight mb-2 uppercase italic">ArmaCloud</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-card/50 backdrop-blur-xl border border-border/50 rounded-3xl p-8 shadow-2xl space-y-6">
|
||||
{/* Signed-in confirmation */}
|
||||
<div className="flex items-center gap-4 p-4 bg-emerald-500/5 border border-emerald-500/20 rounded-2xl">
|
||||
<div className="w-10 h-10 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-foreground text-sm">Signed in as <span className="text-primary">{pendingLogin.username}</span></p>
|
||||
<p className="text-xs text-muted-foreground">Password login successful</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passkey pitch */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-primary/10 border border-primary/20 rounded-xl flex-shrink-0 mt-0.5">
|
||||
<Fingerprint className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-black text-xl text-foreground mb-1">Use a passkey next time?</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Passkeys use your device's fingerprint, face ID, or PIN — no password to remember.
|
||||
Setting one up takes just a few seconds and replaces your password entirely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 pl-1">
|
||||
{['Faster sign-in, no password needed', 'Phishing-resistant by design', 'Stored securely on your device'].map(item => (
|
||||
<li key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ArrowRight className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{passkeySetupError && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-xl flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{passkeySetupError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleSetupPasskey}
|
||||
disabled={passkeySetupLoading}
|
||||
className="w-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground font-black uppercase tracking-widest py-4 rounded-xl transition-all duration-200 shadow-lg shadow-primary/20 active:scale-95 flex items-center justify-center gap-2"
|
||||
>
|
||||
{passkeySetupLoading ? (
|
||||
<><Loader2 className="w-5 h-5 animate-spin" /> Setting up...</>
|
||||
) : (
|
||||
<><Fingerprint className="w-5 h-5" /> Set up Passkey</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSkipPasskey}
|
||||
disabled={passkeySetupLoading}
|
||||
className="w-full py-3 text-sm text-muted-foreground hover:text-foreground transition-colors font-medium"
|
||||
>
|
||||
Skip for now — continue with password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[10px] font-bold text-muted-foreground/30 mt-8 uppercase tracking-[0.2em]">
|
||||
ArmaCloud © 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!supported && authMethod === 'passkey') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0b10] flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full bg-red-500/5 border border-red-500/20 rounded-2xl p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-red-200 mb-2">Passkeys Not Supported</h2>
|
||||
<p className="text-red-400/80 text-sm mb-4">
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full bg-destructive/10 border border-destructive/20 rounded-2xl p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-destructive mb-2">Passkeys Not Supported</h2>
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
Your browser does not support WebAuthn. Please use a modern browser or switch to password login.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setAuthMethod('password')}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-6 py-2 rounded-xl font-bold text-sm transition-colors"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground px-6 py-2 rounded-xl font-bold text-sm transition-colors"
|
||||
>
|
||||
Use Password Instead
|
||||
</button>
|
||||
@@ -154,21 +319,21 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0b10] flex items-center justify-center p-6 relative overflow-hidden">
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-48 w-96 h-96 bg-indigo-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||
<div className="absolute top-1/4 -left-48 w-96 h-96 bg-primary/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 -right-48 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full relative z-10">
|
||||
{/* Logo & Branding */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-600 rounded-2xl mb-4 shadow-2xl shadow-indigo-600/50">
|
||||
<Zap className="w-8 h-8 text-white fill-white" />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl mb-4 shadow-2xl shadow-primary/50">
|
||||
<Zap className="w-8 h-8 text-primary-foreground fill-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-white tracking-tight mb-2">Welcome Back</h1>
|
||||
<p className="text-slate-500 text-sm font-medium">Sign in to your secure vault</p>
|
||||
<h1 className="text-3xl font-black text-foreground tracking-tight mb-2 uppercase italic italic">ArmaCloud</h1>
|
||||
<p className="text-muted-foreground text-sm font-medium tracking-widest uppercase">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{/* Auth Method Selector */}
|
||||
@@ -177,8 +342,8 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
onClick={() => setAuthMethod('password')}
|
||||
className={`flex-1 px-4 py-3 rounded-xl font-bold text-sm transition-all ${
|
||||
authMethod === 'password'
|
||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-4 h-4 inline mr-2" />
|
||||
@@ -188,8 +353,8 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
onClick={() => setAuthMethod('passkey')}
|
||||
className={`flex-1 px-4 py-3 rounded-xl font-bold text-sm transition-all ${
|
||||
authMethod === 'passkey'
|
||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Fingerprint className="w-4 h-4 inline mr-2" />
|
||||
@@ -198,40 +363,40 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-[#0c0d14] border border-slate-800 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="bg-card/50 backdrop-blur-xl border border-border/50 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<div className="p-2 bg-indigo-500/10 border border-indigo-500/20 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-indigo-400" />
|
||||
<div className="p-2 bg-primary/10 border border-primary/20 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">{authMethod === 'password' ? 'Password Login' : 'Passkey Login'}</h2>
|
||||
<p className="text-xs text-slate-500">Zero-Trust Authentication</p>
|
||||
<h2 className="text-lg font-bold text-foreground">{authMethod === 'password' ? 'Sign in' : 'Sign in with Passkey'}</h2>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Welcome back</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={authMethod === 'password' ? handlePasswordLogin : handlePasskeyLogin} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-300 mb-2">Username</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="Operator ID"
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authMethod === 'password' && (
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-300 mb-2">Password</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••••••"
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -239,11 +404,11 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start space-x-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-xl flex items-start space-x-3">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-red-200">Authentication Failed</h3>
|
||||
<p className="text-xs text-red-400/80 mt-1">{error}</p>
|
||||
<h3 className="text-sm font-bold text-destructive">Login failed</h3>
|
||||
<p className="text-xs text-destructive/80 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -251,48 +416,41 @@ export const LoginV2: React.FC<LoginProps> = ({ onLoginSuccess, onSwitchToRegist
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !username.trim() || (authMethod === 'password' && !password.trim())}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-600 text-white font-bold py-4 rounded-xl transition-all duration-200 shadow-lg shadow-indigo-600/20 active:scale-95 flex items-center justify-center space-x-2"
|
||||
className="w-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground font-black uppercase tracking-widest py-4 rounded-xl transition-all duration-200 shadow-lg shadow-primary/20 active:scale-95 flex items-center justify-center space-x-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Authenticating...</span>
|
||||
<span>Verifying...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{authMethod === 'password' ? <Shield className="w-5 h-5" /> : <Fingerprint className="w-5 h-5" />}
|
||||
<span>Sign in with {authMethod === 'password' ? 'Password' : 'Passkey'}</span>
|
||||
<span>Sign In</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-800">
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div className="p-4 bg-slate-900/50 rounded-xl">
|
||||
<div className="text-2xl font-black text-indigo-400">E2EE</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-1">Zero-Knowledge</div>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-900/50 rounded-xl">
|
||||
<div className="text-2xl font-black text-green-400">DSGVO</div>
|
||||
<div className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-1">Compliant</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-6 border-t border-border/50">
|
||||
<p className="text-[11px] text-muted-foreground/50 text-center leading-relaxed">
|
||||
Your data is end-to-end encrypted. Encryption keys never leave your device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Switch to Register */}
|
||||
<p className="text-center text-sm text-slate-500 mt-6">
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
Don't have an account?{' '}
|
||||
<button onClick={onSwitchToRegister} className="text-indigo-400 hover:text-indigo-300 font-bold transition-colors">
|
||||
Create one now
|
||||
<button onClick={onSwitchToRegister} className="text-primary hover:text-primary/80 font-black transition-colors uppercase tracking-widest">
|
||||
Create account
|
||||
</button>
|
||||
</p>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-slate-600 mt-4">
|
||||
Protected by {authMethod === 'passkey' ? 'FIDO2 WebAuthn' : 'Argon2id'} security
|
||||
<p className="text-center text-[10px] font-bold text-muted-foreground/30 mt-8 uppercase tracking-[0.2em]">
|
||||
ArmaCloud © 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Shield, Zap, AlertCircle, Loader2, CheckCircle, Fingerprint, Key } from 'lucide-react';
|
||||
|
||||
interface RegisterProps {
|
||||
onRegisterSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string) => void;
|
||||
onRegisterSuccess: (token: string, masterKey: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void;
|
||||
onSwitchToLogin: () => void;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
const data = await res.json();
|
||||
const masterKeyBytes = new TextEncoder().encode(data.masterKey);
|
||||
|
||||
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, data.username);
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, data.username, data.userId, data.role);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
@@ -140,7 +141,8 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
const data = await finishRes.json();
|
||||
const masterKeyBytes = new TextEncoder().encode(data.masterKey || 'this-is-a-32-byte-master-key-xyz');
|
||||
|
||||
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, username);
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
onRegisterSuccess(data.token, masterKeyBytes, data.communityId, username, data.userId ?? 'user-passkey', data.role);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
@@ -169,21 +171,21 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0b10] flex items-center justify-center p-6 relative overflow-hidden">
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-48 w-96 h-96 bg-indigo-600/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 -right-48 w-96 h-96 bg-purple-600/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||
<div className="absolute top-1/4 -left-48 w-96 h-96 bg-primary/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 -right-48 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md w-full relative z-10">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-600 rounded-2xl mb-4 shadow-2xl shadow-indigo-600/50">
|
||||
<Zap className="w-8 h-8 text-white fill-white" />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl mb-4 shadow-2xl shadow-primary/50">
|
||||
<Zap className="w-8 h-8 text-primary-foreground fill-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-white tracking-tight mb-2">Create Account</h1>
|
||||
<p className="text-slate-500 text-sm font-medium">Zero-Knowledge Gaming Infrastructure</p>
|
||||
<h1 className="text-3xl font-black text-foreground tracking-tight mb-2 uppercase italic">ArmaCloud</h1>
|
||||
<p className="text-muted-foreground text-sm font-medium uppercase tracking-widest">Create your account</p>
|
||||
</div>
|
||||
|
||||
{/* Auth Method Selector */}
|
||||
@@ -192,8 +194,8 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
onClick={() => setAuthMethod('password')}
|
||||
className={`flex-1 px-4 py-3 rounded-xl font-bold text-sm transition-all ${
|
||||
authMethod === 'password'
|
||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Key className="w-4 h-4 inline mr-2" />
|
||||
@@ -203,8 +205,8 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
onClick={() => setAuthMethod('passkey')}
|
||||
className={`flex-1 px-4 py-3 rounded-xl font-bold text-sm transition-all ${
|
||||
authMethod === 'passkey'
|
||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-600/20'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Fingerprint className="w-4 h-4 inline mr-2" />
|
||||
@@ -213,30 +215,30 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
</div>
|
||||
|
||||
{/* Register Card */}
|
||||
<div className="bg-[#0c0d14] border border-slate-800 rounded-3xl p-8 shadow-2xl">
|
||||
<div className="bg-card/50 backdrop-blur-xl border border-border/50 rounded-3xl p-8 shadow-2xl">
|
||||
<form onSubmit={authMethod === 'password' ? handlePasswordRegister : handlePasskeyRegister} className="space-y-5">
|
||||
{/* Common Fields */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-300 mb-2">Username</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="your_username"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="Operator ID"
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-300 mb-2">Email (Optional)</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Email (optional)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="operator@backbone.net"
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -244,8 +246,8 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
{/* Password-specific fields */}
|
||||
{authMethod === 'password' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-300 mb-2">Password</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -253,36 +255,36 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
setPassword(e.target.value);
|
||||
checkPasswordStrength(e.target.value);
|
||||
}}
|
||||
placeholder="Min. 12 characters"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••••••"
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{passwordStrength && password.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
passwordStrength.score <= 1 ? 'bg-red-500' :
|
||||
passwordStrength.score === 2 ? 'bg-yellow-500' :
|
||||
passwordStrength.score === 3 ? 'bg-blue-500' : 'bg-green-500'
|
||||
passwordStrength.score <= 1 ? 'bg-destructive' :
|
||||
passwordStrength.score === 2 ? 'bg-amber-500' :
|
||||
passwordStrength.score === 3 ? 'bg-blue-500' : 'bg-emerald-500'
|
||||
}`}
|
||||
style={{ width: `${(passwordStrength.score / 5) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{passwordStrength.message}</span>
|
||||
<span className="text-[9px] font-black uppercase text-muted-foreground/60">{passwordStrength.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-300 mb-2">Confirm Password</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Repeat password"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="••••••••••••"
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -290,24 +292,24 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-300 mb-2">Community Name (Optional)</label>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/60">Community Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={communityName}
|
||||
onChange={(e) => setCommunityName(e.target.value)}
|
||||
placeholder="e.g., Elite Tactical Gaming"
|
||||
className="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
||||
placeholder="e.g., Tactical Command Alpha"
|
||||
className="w-full px-4 py-3 bg-background border border-border/50 rounded-xl text-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start space-x-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-xl flex items-start space-x-3">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-red-200">Registration Failed</h3>
|
||||
<p className="text-xs text-red-400/80 mt-1">{error}</p>
|
||||
<h3 className="text-sm font-bold text-destructive">Registration failed</h3>
|
||||
<p className="text-xs text-destructive/80 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -315,35 +317,35 @@ export const Register: React.FC<RegisterProps> = ({ onRegisterSuccess, onSwitchT
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !username.trim() || (authMethod === 'password' && (!password || !confirmPassword))}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-600 text-white font-bold py-4 rounded-xl transition-all duration-200 shadow-lg shadow-indigo-600/20 active:scale-95 flex items-center justify-center space-x-2"
|
||||
className="w-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground font-black uppercase tracking-widest py-4 rounded-xl transition-all duration-200 shadow-lg shadow-primary/20 active:scale-95 flex items-center justify-center space-x-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Creating Account...</span>
|
||||
<span>Processing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{authMethod === 'password' ? <Shield className="w-5 h-5" /> : <Fingerprint className="w-5 h-5" />}
|
||||
<span>Create Account with {authMethod === 'password' ? 'Password' : 'Passkey'}</span>
|
||||
<span>Create Account</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Security Info */}
|
||||
<div className="mt-6 pt-6 border-t border-slate-800">
|
||||
<div className="flex items-start space-x-3 text-xs text-slate-500">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<p>Your master encryption key is generated client-side and never leaves your device unencrypted.</p>
|
||||
<div className="mt-6 pt-6 border-t border-border/50">
|
||||
<div className="flex items-start space-x-3 text-[10px] font-medium text-muted-foreground/60">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<p>Your data is end-to-end encrypted. Encryption keys never leave your device.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Switch to Login */}
|
||||
<p className="text-center text-sm text-slate-500 mt-6">
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
Already have an account?{' '}
|
||||
<button onClick={onSwitchToLogin} className="text-indigo-400 hover:text-indigo-300 font-bold transition-colors">
|
||||
<button onClick={onSwitchToLogin} className="text-primary hover:text-primary/80 font-black transition-colors uppercase tracking-widest">
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@@ -1,72 +1,135 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import React, { createContext, useContext, useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface VaultContextType {
|
||||
isLocked: boolean;
|
||||
isAuthenticated: boolean;
|
||||
communityId: string | null;
|
||||
unlock: (key: Uint8Array, communityId: string) => void;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
role: string | null;
|
||||
unlock: (key: Uint8Array, communityId: string, username: string, userId: string, role?: string) => void;
|
||||
lock: () => void;
|
||||
logout: () => void;
|
||||
decrypt: (data: Uint8Array) => Promise<string>;
|
||||
encrypt: (text: string) => Promise<Uint8Array>;
|
||||
hash: (text: string) => Promise<string>;
|
||||
}
|
||||
|
||||
const VaultContext = createContext<VaultContextType | undefined>(undefined);
|
||||
|
||||
const makeWorker = () =>
|
||||
new Worker(new URL('../workers/crypto.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
export const VaultProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isLocked, setIsLocked] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [communityId, setCommunityId] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize the worker
|
||||
workerRef.current = new Worker(new URL('../workers/crypto.worker.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
|
||||
workerRef.current = makeWorker();
|
||||
return () => {
|
||||
workerRef.current?.terminate();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const unlock = (key: Uint8Array, community: string) => {
|
||||
const unlock = (key: Uint8Array, community: string, user: string, uid: string, userRole = 'member') => {
|
||||
workerRef.current?.postMessage({ type: 'SET_KEY', payload: key });
|
||||
setIsLocked(false);
|
||||
setIsAuthenticated(true);
|
||||
setCommunityId(community);
|
||||
setUsername(user);
|
||||
setUserId(uid);
|
||||
setRole(userRole);
|
||||
};
|
||||
|
||||
const lock = () => {
|
||||
// We don't just set the state, we terminate and recreate the worker to clear the memory
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = new Worker(new URL('../workers/crypto.worker.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
workerRef.current = makeWorker();
|
||||
setIsLocked(true);
|
||||
setIsAuthenticated(false);
|
||||
setCommunityId(null);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
setRole(null);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
lock();
|
||||
};
|
||||
|
||||
const decrypt = (data: Uint8Array): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isLocked) return reject('Vault is locked');
|
||||
if (isLocked || !workerRef.current) return reject('Vault is locked');
|
||||
|
||||
const requestId = Math.random().toString(36).slice(2);
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'DECRYPTED') {
|
||||
if (e.data.id === requestId) {
|
||||
workerRef.current?.removeEventListener('message', handleMessage);
|
||||
resolve(e.data.payload);
|
||||
} else if (e.data.type === 'ERROR') {
|
||||
workerRef.current?.removeEventListener('message', handleMessage);
|
||||
reject(e.data.message);
|
||||
if (e.data.type === 'DECRYPTED') {
|
||||
resolve(e.data.payload);
|
||||
} else if (e.data.type === 'ERROR') {
|
||||
reject(e.data.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
workerRef.current?.addEventListener('message', handleMessage);
|
||||
workerRef.current?.postMessage({ type: 'DECRYPTED', payload: data });
|
||||
workerRef.current.addEventListener('message', handleMessage);
|
||||
workerRef.current.postMessage({ type: 'DECRYPT', payload: data, id: requestId });
|
||||
});
|
||||
};
|
||||
|
||||
const encrypt = (text: string): Promise<Uint8Array> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isLocked || !workerRef.current) return reject('Vault is locked');
|
||||
|
||||
const requestId = Math.random().toString(36).slice(2);
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.id === requestId) {
|
||||
workerRef.current?.removeEventListener('message', handleMessage);
|
||||
if (e.data.type === 'ENCRYPTED') {
|
||||
resolve(e.data.payload);
|
||||
} else if (e.data.type === 'ERROR') {
|
||||
reject(e.data.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
workerRef.current.addEventListener('message', handleMessage);
|
||||
workerRef.current.postMessage({ type: 'ENCRYPT', payload: text, id: requestId });
|
||||
});
|
||||
};
|
||||
|
||||
const hash = (text: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isLocked || !workerRef.current) return reject('Vault is locked');
|
||||
|
||||
const requestId = Math.random().toString(36).slice(2);
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.id === requestId) {
|
||||
workerRef.current?.removeEventListener('message', handleMessage);
|
||||
if (e.data.type === 'HASHED') {
|
||||
resolve(e.data.payload);
|
||||
} else if (e.data.type === 'ERROR') {
|
||||
reject(e.data.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
workerRef.current.addEventListener('message', handleMessage);
|
||||
workerRef.current.postMessage({ type: 'BLIND_INDEX', payload: text, id: requestId });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VaultContext.Provider value={{ isLocked, isAuthenticated, communityId, unlock, lock, decrypt }}>
|
||||
<VaultContext.Provider value={{ isLocked, isAuthenticated, communityId, username, userId, role, unlock, lock, logout, decrypt, encrypt, hash }}>
|
||||
{children}
|
||||
</VaultContext.Provider>
|
||||
);
|
||||
|
||||
@@ -36,29 +36,6 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
@@ -78,6 +55,7 @@
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
export async function importKey(keyData: Uint8Array): Promise<CryptoKey> {
|
||||
return await self.crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
keyData.buffer as ArrayBuffer,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["decrypt", "encrypt"]
|
||||
@@ -33,6 +33,55 @@ export async function decryptLog(
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
export async function encryptData(
|
||||
plaintext: string,
|
||||
key: CryptoKey
|
||||
): Promise<Uint8Array> {
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(plaintext);
|
||||
const nonce = self.crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
const encrypted = await self.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: nonce,
|
||||
},
|
||||
key,
|
||||
data
|
||||
);
|
||||
|
||||
const result = new Uint8Array(nonce.length + encrypted.byteLength);
|
||||
result.set(nonce);
|
||||
result.set(new Uint8Array(encrypted), nonce.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generateBlindIndex(
|
||||
value: string,
|
||||
key: Uint8Array
|
||||
): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(value);
|
||||
|
||||
const hmacKey = await self.crypto.subtle.importKey(
|
||||
"raw",
|
||||
key,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
|
||||
const signature = await self.crypto.subtle.sign(
|
||||
"HMAC",
|
||||
hmacKey,
|
||||
data
|
||||
);
|
||||
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a 256-bit key from a password and salt using PBKDF2.
|
||||
* (Used if WebAuthn is not providing a raw key directly)
|
||||
|
||||
@@ -90,7 +90,7 @@ export async function registerWebAuthn(
|
||||
name: options.user.name,
|
||||
displayName: options.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
pubKeyCredParams: options.pubKeyCredParams as PublicKeyCredentialParameters[],
|
||||
timeout: options.timeout,
|
||||
attestation: options.attestation as AttestationConveyancePreference,
|
||||
authenticatorSelection: options.authenticatorSelection as AuthenticatorSelectionCriteria,
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
// crypto.worker.ts
|
||||
import { importKey, decryptLog } from '../lib/crypto';
|
||||
import { importKey, decryptLog, encryptData, generateBlindIndex } from '../lib/crypto';
|
||||
|
||||
let cryptoKey: CryptoKey | null = null;
|
||||
let rawKey: Uint8Array | null = null;
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
const { type, payload } = event.data;
|
||||
const { type, payload, id } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'SET_KEY':
|
||||
// payload is the raw Uint8Array key
|
||||
rawKey = payload;
|
||||
cryptoKey = await importKey(payload);
|
||||
self.postMessage({ type: 'KEY_READY' });
|
||||
self.postMessage({ type: 'KEY_READY', id });
|
||||
break;
|
||||
|
||||
case 'DECRYPT':
|
||||
if (!cryptoKey) {
|
||||
self.postMessage({ type: 'ERROR', message: 'No key set' });
|
||||
self.postMessage({ type: 'ERROR', message: 'No key set', id });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const decrypted = await decryptLog(payload, cryptoKey);
|
||||
self.postMessage({ type: 'DECRYPTED', payload: decrypted });
|
||||
self.postMessage({ type: 'DECRYPTED', payload: decrypted, id });
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'ERROR', message: 'Decryption failed' });
|
||||
self.postMessage({ type: 'ERROR', message: 'Decryption failed', id });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ENCRYPT':
|
||||
if (!cryptoKey) {
|
||||
self.postMessage({ type: 'ERROR', message: 'No key set', id });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const encrypted = await encryptData(payload, cryptoKey);
|
||||
self.postMessage({ type: 'ENCRYPTED', payload: encrypted, id });
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'ERROR', message: 'Encryption failed', id });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BLIND_INDEX':
|
||||
if (!rawKey) {
|
||||
self.postMessage({ type: 'ERROR', message: 'No key set', id });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hash = await generateBlindIndex(payload, rawKey);
|
||||
self.postMessage({ type: 'HASHED', payload: hash, id });
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'ERROR', message: 'Hashing failed', id });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user