22 Commits

Author SHA1 Message Date
Jonas 0c37a9185b Update AppUserController.cs 2026-05-03 15:59:20 +02:00
Jonas 178bc8731e Add admin user creation & must-change flag
Add server and UI support for creating admin users and forcing password change. API: introduce CreateUserRequest contract and add CreateNewAppUser endpoint in AppUserController; extend ChangeUserRequest with MustChangePassword and handle role assignment and detailed error responses (409/422/400). Frontend: new CreateUserDialog component, integrate it into AdminUsers list, and add createAdminUser service with CreateAdminUserError and payload handling; include mustChangePassword in update payloads and EditUserDialog. UI polish: enhanced app banner enter/leave animations in Layout.vue and add auto-dismiss timers/cleanup to appBanners store to limit and auto-remove banners.
2026-05-03 15:56:28 +02:00
Jonas 1d00fb3a4b Admin user edit: UI, API and server guard
Add full admin user editing flow: introduce EditUserDialog component and integrate it into AdminUserDetail (with minor copy and button variant tweaks), plus layout tweaks to animate the account chevron. Implement updateAdminUser(...) in GUI services to PATCH /auth/user/{id} with comprehensive error handling and export FORBIDDEN_NOT_ADMIN_MESSAGE. Server-side AppUserController now prevents deactivating users in the Admin role and returns a 403, ensuring admin accounts cannot be disabled. These changes enable editing usernames and activation status from the admin UI while protecting admin accounts.
2026-05-01 15:40:54 +02:00
Jonas 847ac119d8 Use PATCH, fix message, remove username check
Update API/Controllers/Auth/AppUserController.cs: change route attribute to [HttpPatch("{id:guid}")] (fixing verb and missing bracket), correct German error message to indicate the user was deactivated, and remove the redundant existing-username conflict check before SetUserNameAsync. These changes clarify intent and rely on userManager to handle username validation.
2026-05-01 15:25:21 +02:00
Jonas b29d174141 Add user update endpoint and DTO
Introduce ChangeUserRequest DTO (UserName, IsActive) and add UpdateAppUser action to AppUserController. The new endpoint allows updating a user's username and active state, trims and validates the username, checks for duplicates, updates the Identity security stamp when deactivating to invalidate sessions, and returns appropriate success or error responses.
2026-05-01 15:22:42 +02:00
Jonas 7e2ca4c9e2 Rename hoard- to ui- and add UI components
Mass rename of CSS classes, tokens and animations from the hoard- namespace to ui- (classes, variables like --ui-*, and keyframes). Introduces new UI components: EmptyState, StatusPill, and UserAvatar and updates admin views to import and use them. Updates many route/layout components and global.css to use the new ui- patterns and responsive variables. Also updates Impressum contact emails and adds .claude/settings.local.json to allow running npm scripts in the Claude local settings.
2026-04-28 21:52:22 +02:00
Jonas a512aaa0a7 Merge branch 'identity/develop' of https://github.com/kobolol/Hoard into identity/develop 2026-04-28 21:30:45 +02:00
Jonas 529ea77d13 Merge pull request #2 from kobolol/claude/review-project-rating-WVHNV 2026-04-28 12:28:13 +02:00
Claude 2c882fce4a Extrahiere Sidebar-Routen-Logik in Composable und entferne toten Counter-Store
Layout.vue trug die Sidebar-Filterlogik (Visibility/Rolle/Pfadabgleich)
inline. Sie liegt jetzt in composables/useSidebarRoutes.ts; Layout.vue
verliert ~85 Zeilen Skript ohne Verhaltensänderung. Der ungenutzte
stores/counter.ts (Vite-Boilerplate) wird entfernt.
2026-04-28 10:22:32 +00:00
Jonas 3c780e292b Improve tooltip styles and guard search query
Add Vuetify tooltip CSS for better contrast and readability in light/dark themes (sets background/text colors, font-size, weight, padding, radius, shadow and forces opacity) in GUI/src/global.css. In AdminUsers.vue, guard against null/undefined `searchQuery.value` by using `searchQuery.value ?? ''` before trimming and lowercasing to prevent runtime errors when the query is unset.
2026-04-26 19:05:15 +02:00
Claude 6740038e9a Modernize frontend: new design system, redesign all pages
- Convert codexInfo.md to CLAUDE.md as the central project context.
- Rewrite GUI/style.md with a modernized, file-first direction (layered
  surfaces, refined typography, motion budget, ambient gradients).
- Refresh global tokens in global.css, page-layouts.css and
  surface-patterns.css: extended palette (primary 050/800, surface
  elevated, border subtle), four-tier shadow system, dark-mode parity,
  new utilities (hoard-chip, hoard-icon-tile, hoard-spotlight,
  hoard-section-head, hoard-divider-soft, status pulse dot).
- Rebuild Layout.vue: premium app shell with brand halo, animated
  active-indicator, account pill with avatar/initials, drawer footer
  card, refined banner stack and footer.
- Redesign every route while preserving routing and API contracts:
  Home, Login, ChangePassword, Dashboard, AdminUsers, AdminUserDetail,
  Impressum, 404, Forbidden. Adds search/admin stats, password hint
  list, dashboard greeting with avatar, modernized hero/spotlight
  treatments and consistent mobile layouts (safe-areas, 44/48px tap
  targets).
- Drop @fontsource/roboto import in vuetify.ts and load Inter via the
  rsms.me CSS in index.html; update Vuetify defaults and palette to
  match the new tokens.
2026-04-26 15:24:52 +00:00
Jonas 10bf4b94ad UI refresh: animation, cards, responsive tweaks
Large frontend modernization: add route fade transition and hoard-soft-enter keyframes with prefers-reduced-motion support; introduce smoother motion tokens and stronger shadow tokens. Update global CSS to use subtle surface gradients, unified transitions, hover lift effects, focus rings and improved button/card/table/overlay styles. Wrap <router-view> in a transition and adjust brand/logo sizing and interactions. Revamp several pages/components (Home, Login, Impressum, 404, Forbidden) with adjusted typography, animated entry for sections and improved card hover states. Admin/Dashboard pages enhanced: AdminUsers gains stats, mobile card list & computed counts; AdminUserDetail and Dashboard show compact summary cards and updated styles. Documentation updated (style.md, codexInfo.md) to reflect the new modernisation rules. No API or backend changes.
2026-04-23 22:07:07 +02:00
Jonas 3f826546ea Komprimiere 'Änderungen durch Codex' Abschnitt
Ersetzt die lange, detaillierte Bullet-Liste unter „Änderungen durch Codex“ in codexInfo.md durch drei kompakte Kernzeilen (Frontend/UI, Backend/API, Infrastruktur/Build), um die Datei übersichtlicher und lesbarer zu machen. Die detaillierten Änderungen bleiben in den jeweiligen Dateien und im Commit-Verlauf erhalten.
2026-04-20 21:09:24 +02:00
Jonas 14176a3ee2 Add admin user management and password-change flow
Introduce full admin user listing/detail endpoints and a forced password-change flow. Backend: make CurrentUserResponse.UserName nullable and add ToCurrentUserResponseAsync extension; AppUserController now exposes GET /auth/user (list) and GET /auth/user/{id} (detail) using UserManager and Admin-only policy; AuthController uses the new mapper and after successful password change clears MustChangePassword, updates UpdatedAt and persists changes (with error handling) before updating security stamp. Frontend: add admin users pages (list + detail), ChangePassword page and route, adminUsers and enhanced authSession services (typed responses, changePassword API, error mapping), router guard to redirect users with mustChangePassword=true to the change-password flow, and show success banner on login after password change. UI tweaks: separate admin section in sidebar, add password-change entries in account menu, footer sizing fixes, and various layout/UX improvements. These changes enable admin account management and enforce secure password updates across the app.
2026-04-20 21:02:16 +02:00
Jonas b2984fcf1a Replace IsAdmin with role-based admin
Switch user admin handling from an AppUser boolean to ASP.NET Identity roles. Removed AppUser.IsAdmin and related configuration/model entries; added migration ReplaceIsAdminWithRoles to copy Users.IsAdmin=true into a persistent admin role and drop the IsAdmin column. CurrentUserResponse now exposes roles (string[]), AuthController returns ordered roles from UserManager, and IdentitySeedService now ensures the admin role exists and assigns/creates an initial admin user in that role. Program.cs registers an Admin-only policy (PolicyNames/RoleNames), adjusts cookie auth events to return 401/403 for API requests, and wires up authorization. Frontend updated to use roles: authSession normalizes roles, adds hasRole and ROLE_ADMIN, router and layout support meta.requiredRoles, and new Forbidden and AdminUsers pages/route are added. codexInfo.md updated to reflect the migration to role-based auth.
2026-04-20 19:57:49 +02:00
Jonas bd261b6868 Add change-password API and dynamic 404 redirect
Introduce ChangePasswordRequest DTO and a new ChangePassword endpoint in AuthController that validates input, changes the user's password via UserManager, updates the security stamp, signs out the user to invalidate sessions, and returns localized messages. Add a simple authorized AppUserController stub (GET /auth/user). Update the 404 view to resolve auth status via fetchCurrentUser, show a dynamic CTA/icon (Dashboard vs Home), auto-redirect after a short delay with proper timer cleanup, and adjust navigation behavior. Update codexInfo.md to document the 404 behavior change.
2026-04-20 19:39:43 +02:00
Jonas f830fe4967 Brand/404: route to Dashboard and sync user
Ensure brand click reliably routes to Dashboard by making navigateToBrandTarget async and fetching current user if not yet loaded (prevents misnavigation to Welcome). Update mobile account menu label to "Zum Dashboard". Change 404 page primary CTA/text to point to the Dashboard and make navigateBack fall back to Dashboard. Update codexInfo.md to document these changes and note the new codexinfo-komprimieren skill.
2026-04-18 23:18:50 +02:00
Jonas 6c2a149f96 Add global app banners and integrate into layout
Introduce a global app banners store (GUI/src/stores/appBanners.ts) and switch authentication error/notification flows to use it. GUI/src/Layout.vue now consumes the banner store, replaces the single snackbar with a stacked, dismissible banner UI (styles, transitions and z-index included), and adds navigateToBrandTarget() to route the brand button based on auth state. GUI/src/routes/authentication/Login.vue was updated to push errors to the new banner store and remove the local alert. Minor route adjustments in GUI/src/plugins/routesLayout.ts: rename 'Dash' to 'Dashboard' and change Login visibility to Unauthenticated. codexInfo.md updated to document these UI/behavior changes.
2026-04-18 23:02:01 +02:00
Jonas 86ed227566 Add frontend auth, dashboard & router guards
Introduce a complete frontend auth flow and protected dashboard.

- Add auth session module (GUI/src/services/authSession.ts) with fetchCurrentUser, login, logout, caching and structured errors.
- Add Dashboard page (GUI/src/routes/dashboard/Dashboard.vue) and a protected Dashboard route (meta.requiresAuth) at '/'.
- Move public landing page to /welcome and mark it Visibility.Unauthenticated; update 404 and Impressum links.
- Implement router guard (GUI/src/router/index.ts) to redirect unauthenticated users to Login and prevent logged-in users from accessing guest-only pages.
- Update routes layout (GUI/src/plugins/routesLayout.ts) to include authenticated/unauthenticated visibility and dashboard entry.
- Update Layout.vue to track current user, show username/menu, conditionally render sidebar items, add logout flow and error snackbar, and insert visual divider before auth-only items.
- Convert Login.vue into a working login form with loading state, error handling and redirect after success.
- Update codexInfo.md to document the new auth features and related UI/route changes.
2026-04-18 22:42:17 +02:00
Jonas 38ec3741ab Document ASP.NET Identity & auth additions
Update codexInfo.md to reflect backend changes: add auth endpoints (POST /auth/login, POST /auth/logout, GET /auth/me) alongside existing health check; introduce ASP.NET Identity with AppUser (Guid key, IsAdmin/IsActive/MustChangePassword/CreatedAt/UpdatedAt) and IdentityDbContext mapping; add InitIdentity migration and auto-apply on startup; add IdentitySeedService to create an initial admin if missing; remove temporary test entity/endpoints and include RemoveTestItems migration; update API project dependencies and add AppUser configuration, Auth DTOs and AuthController; document Program.cs changes for Identity registration, cookie config (hoard.auth), UseAuthentication/UseAuthorization, startup seeding and enhanced logging; preserve notes about local dev docker-compose and appsettings.custom.json behavior.
2026-04-18 22:09:31 +02:00
Jonas fc99c91bd8 Add auth controller/DTOs and update seed password
Introduce authentication API: add AuthController with login, logout and me endpoints using SignInManager/UserManager; add LoginRequest and CurrentUserResponse DTOs. Login enforces active users, updates UpdatedAt on success, and returns localized messages. Also change default seed admin password from "Hoard" to "HoardPassword".
2026-04-18 22:04:51 +02:00
Jonas ffaf5d24c1 Add ASP.NET Identity, AppUser, migrations
Introduce ASP.NET Core Identity with Guid keys: add Microsoft.AspNetCore.Identity.EntityFrameworkCore and update EF packages to 10.0.6. Replace DbContext with IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>, apply entity configurations and map Identity tables to custom names (Users, Roles, UserRoles, etc.).

Add AppUser model (IsAdmin, IsActive, MustChangePassword, CreatedAt, UpdatedAt) and AppUserConfiguration to enforce required properties and table name. Add IdentitySeedService to create an initial admin account if none exists and log results.

Add generated migration InitIdentity and update the DbContext model snapshot. Wire up Identity in Program.cs (identity options, cookie config, AddEntityFrameworkStores), enable structured console logging and HTTP request logging, run migrations on startup and call the seed service, and enable authentication/authorization middleware. Update codexInfo.md to document the logging and seeding changes.
2026-04-18 21:54:57 +02:00
52 changed files with 8530 additions and 1297 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run *)"
]
}
}
+2 -1
View File
@@ -7,7 +7,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4"> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace API.Contracts.Auth
{
public class ChangePasswordRequest
{
[Required]
public string OldPassword { get; set; } = string.Empty;
[Required]
public string NewPassword { get; set; } = string.Empty;
[Required]
public string NewPasswordConfirm { get; set; } = string.Empty;
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace API.Contracts.Auth
{
public class ChangeUserRequest
{
public string? UserName { get; set; }
public bool? IsActive { get; set; }
public bool? MustChangePassword { get; set; }
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace API.Contracts.Auth
{
public class CreateUserRequest
{
public required string UserName { get; set; }
public required string StartPassword { get; set; }
public bool IsAdmin { get; set; } = false;
public bool IsActive { get; set; } = true;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace API.Contracts.Auth
{
public class CurrentUserResponse
{
public Guid Id { get; set; }
public string? UserName { get; set; } = string.Empty;
public List<string> Roles { get; set; } = new();
public bool IsActive { get; set; }
public bool MustChangePassword { get; set; }
}
}
@@ -0,0 +1,27 @@
using API.Models;
using Microsoft.AspNetCore.Identity;
namespace API.Contracts.Auth
{
public static class CurrentUserResponseExtensions
{
public static async Task<CurrentUserResponse> ToCurrentUserResponseAsync(
this AppUser user,
UserManager<AppUser> userManager)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(userManager);
var roles = await userManager.GetRolesAsync(user);
return new CurrentUserResponse
{
Id = user.Id,
UserName = user.UserName,
Roles = roles.OrderBy(role => role).ToList(),
IsActive = user.IsActive,
MustChangePassword = user.MustChangePassword,
};
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace API.Contracts.Auth
{
public class LoginRequest
{
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
}
+168
View File
@@ -0,0 +1,168 @@
using API.Contracts.Auth;
using API.Models;
using API.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace API.Controllers.Auth
{
[ApiController]
[Authorize(Policy = PolicyNames.AdminOnly)]
[Route("auth/user")]
public class AppUserController(UserManager<AppUser> userManager) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<CurrentUserResponse>>> GetAppUsers()
{
var users = await userManager.Users
.OrderBy(x => x.UserName)
.ToListAsync();
var responses = new List<CurrentUserResponse>(users.Count);
foreach (var user in users)
{
responses.Add(await user.ToCurrentUserResponseAsync(userManager));
}
return Ok(responses);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<CurrentUserResponse>> GetAppUserById([FromRoute] Guid id)
{
var user = await userManager.Users.FirstOrDefaultAsync(x => x.Id == id);
if (user is null)
{
return NotFound(new { message = "Benutzer wurde nicht gefunden." });
}
return Ok(await user.ToCurrentUserResponseAsync(userManager));
}
[HttpPatch("{id:guid}")]
public async Task<IActionResult> UpdateAppUser([FromRoute] Guid id, [FromBody] ChangeUserRequest changeDto)
{
var user = await userManager.Users.FirstOrDefaultAsync(x => x.Id == id);
if (user is null)
{
return NotFound(new { message = "Benutzer wurde nicht gefunden." });
}
if (changeDto.IsActive != null)
{
if (!changeDto.IsActive.Value && await userManager.IsInRoleAsync(user, RoleNames.Admin))
{
return StatusCode(StatusCodes.Status403Forbidden,
new { message = "Adminkonten können nicht deaktiviert werden." });
}
user.IsActive = changeDto.IsActive.Value;
if (!changeDto.IsActive.Value)
{
var stampResult = await userManager.UpdateSecurityStampAsync(user);
if (!stampResult.Succeeded)
{
return StatusCode(500, new { message = "Benutzer wurde deaktiviert, aber Sessions konnten nicht invalidiert werden. " +
"Er könnte also immer noch Angemeldet sein!" });
}
}
}
if (changeDto.UserName != null)
{
var newUserName = changeDto.UserName.Trim();
if (string.IsNullOrEmpty(newUserName))
{
return BadRequest(new { message = "Benutzername darf nicht leer sein." });
}
if (!string.Equals(newUserName, user.UserName, StringComparison.OrdinalIgnoreCase))
{
var setNameResult = await userManager.SetUserNameAsync(user, newUserName);
if (!setNameResult.Succeeded)
{
if (setNameResult.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName)))
{
return Conflict(new { message = "Benutzername ist bereits vergeben." });
}
return BadRequest(new { message = "Benutzername konnte nicht geändert werden.", errors = setNameResult.Errors.Select(e => e.Description) });
}
}
}
if (changeDto.MustChangePassword != null)
{
user.MustChangePassword = changeDto.MustChangePassword.Value;
await userManager.UpdateAsync(user);
}
return Ok(await user.ToCurrentUserResponseAsync(userManager));
}
[HttpPost]
public async Task<IActionResult> CreateNewAppUser([FromBody] CreateUserRequest createDto)
{
var newUser = new AppUser
{
UserName = createDto.UserName.Trim(),
MustChangePassword = true,
IsActive = createDto.IsActive,
};
var result = await userManager.CreateAsync(newUser, createDto.StartPassword);
if (!result.Succeeded)
{
if (result.Errors.Any(e => e.Code == nameof(IdentityErrorDescriber.DuplicateUserName)))
{
return Conflict(new { message = "Benutzername ist bereits vergeben." });
}
var passwordErrors = result.Errors
.Where(e => e.Code.StartsWith("Password"))
.Select(e => e.Description)
.ToList();
if (passwordErrors.Any())
{
return UnprocessableEntity(new
{
message = "Passwort erfüllt nicht die Sicherheitsanforderungen.",
errors = passwordErrors
});
}
return BadRequest(new
{
message = "Benutzer konnte nicht erstellt werden.",
errors = result.Errors.Select(e => e.Description)
});
}
if (createDto.IsAdmin)
{
var roleResult = await userManager.AddToRoleAsync(newUser, RoleNames.Admin);
if (!roleResult.Succeeded)
{
return BadRequest(new
{
message = "Benutzer wurde erstellt, aber Rolle konnte nicht zugewiesen werden.",
errors = roleResult.Errors.Select(e => e.Description)
});
}
}
return CreatedAtAction(nameof(GetAppUserById), new { id = newUser.Id },
await newUser.ToCurrentUserResponseAsync(userManager));
}
}
}
+122
View File
@@ -0,0 +1,122 @@
using API.Contracts.Auth;
using API.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers.Auth
{
[ApiController]
[Route("auth")]
public class AuthController(
SignInManager<AppUser> signInManager,
UserManager<AppUser> userManager)
: ControllerBase
{
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new { message = "Benutzername und Passwort sind erforderlich." });
var user = await userManager.FindByNameAsync(request.UserName);
if (user is null)
return Unauthorized(new { message = "Ungültige Anmeldedaten." });
if (!user.IsActive)
return Forbid();
var result = await signInManager.PasswordSignInAsync(
user,
request.Password,
isPersistent: true,
lockoutOnFailure: false);
if (!result.Succeeded)
return Unauthorized(new { message = "Ungültige Anmeldedaten." });
user.UpdatedAt = DateTimeOffset.UtcNow;
await userManager.UpdateAsync(user);
return Ok(new { message = "Login erfolgreich." });
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await signInManager.SignOutAsync();
return Ok(new { message = "Logout erfolgreich." });
}
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<CurrentUserResponse>> Me()
{
var user = await userManager.GetUserAsync(User);
if (user is null)
return Unauthorized();
return Ok(await user.ToCurrentUserResponseAsync(userManager));
}
[HttpPost("password")]
[Authorize]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest pwChangeDto)
{
var user = await userManager.GetUserAsync(User);
if (user is null)
return Unauthorized();
if (string.IsNullOrWhiteSpace(pwChangeDto.NewPassword) ||
string.IsNullOrWhiteSpace(pwChangeDto.OldPassword) ||
string.IsNullOrWhiteSpace(pwChangeDto.NewPasswordConfirm))
{
return BadRequest(new { message = "Alle Passwörter müssen einen Wert enthalten." });
}
if (pwChangeDto.NewPassword != pwChangeDto.NewPasswordConfirm)
{
return BadRequest(new { message = "Die neuen Passwörter stimmen nicht überein." });
}
var result = await userManager.ChangePasswordAsync(
user,
pwChangeDto.OldPassword,
pwChangeDto.NewPassword
);
if (!result.Succeeded)
{
return BadRequest(new
{
message = "Passwort konnte nicht geändert werden.",
errors = result.Errors.Select(e => e.Description)
});
}
user.MustChangePassword = false;
user.UpdatedAt = DateTimeOffset.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{
return StatusCode(500, new
{
message = "Passwort wurde geändert, Benutzerdaten konnten aber nicht final gespeichert werden.",
errors = updateResult.Errors.Select(e => e.Description)
});
}
var stampResult = await userManager.UpdateSecurityStampAsync(user);
if (!stampResult.Succeeded)
{
return StatusCode(500, new { message = "Passwort geändert, aber Sessions konnten nicht invalidiert werden." });
}
await signInManager.SignOutAsync();
return Ok(new { message = "Passwort geändert. Du wurdest auf allen Geräten abgemeldet." });
}
}
}
+20 -1
View File
@@ -1,5 +1,24 @@
using API.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace API.Database; namespace API.Database;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options); public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>(options)
{
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
builder.Entity<IdentityRole<Guid>>().ToTable("Roles");
builder.Entity<IdentityUserRole<Guid>>().ToTable("UserRoles");
builder.Entity<IdentityUserClaim<Guid>>().ToTable("UserClaims");
builder.Entity<IdentityUserLogin<Guid>>().ToTable("UserLogins");
builder.Entity<IdentityRoleClaim<Guid>>().ToTable("RoleClaims");
builder.Entity<IdentityUserToken<Guid>>().ToTable("UserTokens");
}
}
@@ -0,0 +1,19 @@
using API.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace API.Database.Configurations
{
public class AppUserConfiguration : IEntityTypeConfiguration<AppUser>
{
public void Configure(EntityTypeBuilder<AppUser> builder)
{
builder.ToTable("Users");
builder.Property(x => x.CreatedAt).IsRequired();
builder.Property(x => x.UpdatedAt).IsRequired();
builder.Property(x => x.IsActive).IsRequired();
builder.Property(x => x.MustChangePassword).IsRequired();
}
}
}
+291
View File
@@ -0,0 +1,291 @@
// <auto-generated />
using System;
using API.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260418192723_InitIdentity")]
partial class InitIdentity
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Models.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsAdmin")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("Roles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,228 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class InitIdentity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Roles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsAdmin = table.Column<bool>(type: "boolean", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
MustChangePassword = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_RoleClaims_Roles_RoleId",
column: x => x.RoleId,
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserClaims", x => x.Id);
table.ForeignKey(
name: "FK_UserClaims_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_UserLogins_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_UserRoles_Roles_RoleId",
column: x => x.RoleId,
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserRoles_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_UserTokens_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_RoleClaims_RoleId",
table: "RoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "Roles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserClaims_UserId",
table: "UserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserLogins_UserId",
table: "UserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserRoles_RoleId",
table: "UserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "Users",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "Users",
column: "NormalizedUserName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RoleClaims");
migrationBuilder.DropTable(
name: "UserClaims");
migrationBuilder.DropTable(
name: "UserLogins");
migrationBuilder.DropTable(
name: "UserRoles");
migrationBuilder.DropTable(
name: "UserTokens");
migrationBuilder.DropTable(
name: "Roles");
migrationBuilder.DropTable(
name: "Users");
}
}
}
@@ -0,0 +1,288 @@
// <auto-generated />
using System;
using API.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260420174609_ReplaceIsAdminWithRoles")]
partial class ReplaceIsAdminWithRoles
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Models.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("Roles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class ReplaceIsAdminWithRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
DO $$
DECLARE
admin_role_id uuid;
BEGIN
SELECT "Id" INTO admin_role_id
FROM "Roles"
WHERE "NormalizedName" = 'ADMIN'
LIMIT 1;
IF admin_role_id IS NULL THEN
admin_role_id := '2b34c0e2-9d53-4d79-bb85-bff03ce9e1ee';
INSERT INTO "Roles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp")
VALUES (admin_role_id, 'admin', 'ADMIN', NULL)
ON CONFLICT ("Id") DO NOTHING;
SELECT "Id" INTO admin_role_id
FROM "Roles"
WHERE "NormalizedName" = 'ADMIN'
LIMIT 1;
END IF;
INSERT INTO "UserRoles" ("UserId", "RoleId")
SELECT u."Id", admin_role_id
FROM "Users" u
WHERE u."IsAdmin" = TRUE
AND admin_role_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM "UserRoles" ur
WHERE ur."UserId" = u."Id"
AND ur."RoleId" = admin_role_id
);
END $$;
""");
migrationBuilder.DropColumn(
name: "IsAdmin",
table: "Users");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAdmin",
table: "Users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.Sql(
"""
UPDATE "Users" u
SET "IsAdmin" = TRUE
WHERE EXISTS (
SELECT 1
FROM "UserRoles" ur
INNER JOIN "Roles" r ON r."Id" = ur."RoleId"
WHERE ur."UserId" = u."Id"
AND r."NormalizedName" = 'ADMIN'
);
""");
}
}
}
@@ -1,4 +1,5 @@
// <auto-generated /> // <auto-generated />
using System;
using API.Database; using API.Database;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -16,10 +17,268 @@ namespace API.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.4") .HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Models.AppUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<bool>("MustChangePassword")
.HasColumnType("boolean");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("Users", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("Roles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("RoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("UserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("API.Models.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+13
View File
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Identity;
namespace API.Models
{
public class AppUser : IdentityUser<Guid>
{
public bool IsActive { get; set; } = true;
public bool MustChangePassword { get; set; } = false;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
}
+97
View File
@@ -1,8 +1,21 @@
using API.Database; using API.Database;
using API.Models;
using API.Security;
using API.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.custom.json", optional: true, reloadOnChange: true); builder.Configuration.AddJsonFile("appsettings.custom.json", optional: true, reloadOnChange: true);
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(options =>
{
options.SingleLine = true;
options.TimestampFormat = "yyyy-MM-dd HH:mm:ss ";
});
builder.Logging.AddDebug();
var connectionString = builder.Configuration.GetConnectionString("Postgres") var connectionString = builder.Configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Connection string 'Postgres' wurde nicht gefunden."); ?? throw new InvalidOperationException("Connection string 'Postgres' wurde nicht gefunden.");
@@ -15,14 +28,88 @@ builder.Services.AddDbContext<ApplicationDbContext>(options =>
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddHttpLogging(options =>
{
options.LoggingFields = HttpLoggingFields.RequestMethod
| HttpLoggingFields.RequestPath
| HttpLoggingFields.ResponseStatusCode
| HttpLoggingFields.Duration;
});
builder.Services
.AddIdentity<AppUser, IdentityRole<Guid>>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireDigit = false;
options.Password.RequireUppercase = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.User.RequireUniqueEmail = false;
options.SignIn.RequireConfirmedAccount = false;
options.SignIn.RequireConfirmedEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(PolicyNames.AdminOnly, policy =>
{
policy.RequireRole(RoleNames.Admin);
});
});
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = "hoard.auth";
options.LoginPath = "/auth/login";
options.LogoutPath = "/auth/logout";
options.AccessDeniedPath = "/auth/forbidden";
options.SlidingExpiration = true;
options.Cookie.HttpOnly = true;
options.Events = new CookieAuthenticationEvents
{
OnRedirectToLogin = context =>
{
if (IsApiRequest(context.Request))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
},
OnRedirectToAccessDenied = context =>
{
if (IsApiRequest(context.Request))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
}
};
});
builder.Services.AddScoped<IdentitySeedService>();
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var startupLogger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
startupLogger.LogInformation("Starte Datenbankmigrationen.");
dbContext.Database.Migrate(); dbContext.Database.Migrate();
var seedService = scope.ServiceProvider.GetRequiredService<IdentitySeedService>();
await seedService.SeedAsync();
startupLogger.LogInformation("Backend-Initialisierung abgeschlossen.");
} }
var webRootPath = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot"); var webRootPath = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
var indexFilePath = Path.Combine(webRootPath, "index.html"); var indexFilePath = Path.Combine(webRootPath, "index.html");
@@ -34,6 +121,10 @@ if (app.Environment.IsDevelopment())
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseHttpLogging();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.MapFallback(async context => app.MapFallback(async context =>
@@ -55,3 +146,9 @@ app.MapFallback(async context =>
}); });
app.Run(); app.Run();
static bool IsApiRequest(HttpRequest request)
{
return request.Path.StartsWithSegments("/api")
|| request.Path.StartsWithSegments("/auth");
}
+7
View File
@@ -0,0 +1,7 @@
namespace API.Security
{
public static class PolicyNames
{
public const string AdminOnly = "admin-only";
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace API.Security
{
public static class RoleNames
{
public const string Admin = "admin";
}
}
+87
View File
@@ -0,0 +1,87 @@
using API.Models;
using API.Security;
using Microsoft.AspNetCore.Identity;
namespace API.Services
{
public class IdentitySeedService(
UserManager<AppUser> userManager,
RoleManager<IdentityRole<Guid>> roleManager,
IConfiguration configuration,
ILogger<IdentitySeedService> logger)
{
public async Task SeedAsync()
{
await EnsureRoleExistsAsync(RoleNames.Admin);
var adminUsers = await userManager.GetUsersInRoleAsync(RoleNames.Admin);
if (adminUsers.Count > 0)
{
logger.LogDebug("Admin-Seed übersprungen: Es existiert bereits ein Admin über Rollen.");
return;
}
var adminUserName = configuration["SeedAdmin:UserName"] ?? "admin";
var adminPassword = configuration["SeedAdmin:Password"] ?? "HoardPassword";
var adminEmail = configuration["SeedAdmin:Email"];
var admin = await userManager.FindByNameAsync(adminUserName);
if (admin is null)
{
admin = new AppUser
{
UserName = adminUserName,
Email = string.IsNullOrWhiteSpace(adminEmail) ? null : adminEmail,
IsActive = true,
MustChangePassword = true,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
var createResult = await userManager.CreateAsync(admin, adminPassword);
if (!createResult.Succeeded)
{
var createErrors = string.Join(", ", createResult.Errors.Select(x => x.Description));
logger.LogError("Admin-Seed fehlgeschlagen: {Errors}", createErrors);
throw new InvalidOperationException($"Admin-Seed fehlgeschlagen: {createErrors}");
}
}
if (!await userManager.IsInRoleAsync(admin, RoleNames.Admin))
{
var roleResult = await userManager.AddToRoleAsync(admin, RoleNames.Admin);
if (!roleResult.Succeeded)
{
var roleErrors = string.Join(", ", roleResult.Errors.Select(x => x.Description));
logger.LogError("Admin-Rollenzuweisung fehlgeschlagen: {Errors}", roleErrors);
throw new InvalidOperationException($"Admin-Rollenzuweisung fehlgeschlagen: {roleErrors}");
}
}
logger.LogInformation(
"Admin-Account wurde geseedet bzw. als Rolle zugewiesen (UserName: {UserName}, Email: {Email}).",
admin.UserName,
admin.Email ?? "(keine)");
}
private async Task EnsureRoleExistsAsync(string roleName)
{
if (await roleManager.RoleExistsAsync(roleName))
{
return;
}
var createRoleResult = await roleManager.CreateAsync(new IdentityRole<Guid>
{
Name = roleName
});
if (!createRoleResult.Succeeded)
{
var roleErrors = string.Join(", ", createRoleResult.Errors.Select(x => x.Description));
logger.LogError("Rolle {RoleName} konnte nicht erstellt werden: {Errors}", roleName, roleErrors);
throw new InvalidOperationException($"Rolle {roleName} konnte nicht erstellt werden: {roleErrors}");
}
}
}
}
+106
View File
@@ -0,0 +1,106 @@
# CLAUDE.md Projektkontext für Hoard
Diese Datei gibt Claude (und allen weiteren Beitragenden) den nötigen Kontext, um an Hoard sinnvoll und konsistent weiterzubauen. Sie ersetzt die alte `codexInfo.md`.
## Projektidee
Hoard ist eine self-hosted Web-App, die sich funktional zwischen Google Drive, Notion und Obsidian einordnet. Der Schwerpunkt liegt auf einer **Google-Drive-artigen Oberfläche** mit Dateien und Ordnern. Markdown-Dateien sollen direkt im Browser bearbeitet werden, andere Dateien gespeichert und wenn möglich als Vorschau angezeigt werden.
## Ziel des Projekts
Hoard ist ein bewusst einfach gehaltenes Solo-Projekt neben einer Ausbildung. Es soll mehrere Benutzer unterstützen, dabei aber technisch und funktional schlank bleiben. Wichtig ist ein realistisches MVP, das sauber läuft und später erweitert werden kann.
## Tech-Stack
- **Frontend:** Vue 3 + TypeScript + Vite + Vuetify 4 + Pinia + Vue Router
- **Markdown-Editor:** md-editor-v3 (geplant)
- **Backend:** ASP.NET Core (C#)
- **Datenbank:** PostgreSQL (EF Core, Migrationen beim Start)
- **Identity:** ASP.NET Identity, Cookie-basierte Auth, Rollenmodell (`admin`)
- **Dateispeicher:** MinIO als S3-kompatibler Storage (geplant)
- **Deployment:** Self-hosted; Frontend-Build geht direkt nach `API/wwwroot`
## Kernfunktionen für das MVP
- Login mit bestehenden Accounts (kein öffentliches Registrieren)
- Initialer Admin-Account, weitere Benutzer manuell durch Admins
- Dateien und Ordner anlegen, hochladen, öffnen, navigieren
- Dateiliste + Vorschau als Hauptansicht
- Markdown direkt im Browser bearbeiten
- PDFs und Bilder als Vorschau
- Andere Dateien speichern, herunterladen oder öffnen
## Was bewusst nicht im MVP ist
- Keine offene Registrierung
- Kein Teilen oder Freigeben
- Keine Suche, keine Versionierung
- Keine Echtzeit-Zusammenarbeit
- Keine Desktop-/Mobile-Apps
- Keine komplexe Rechteverwaltung
- Kein JWT/OAuth/SSO
## Sprachregel für UI-Texte
- Umlaute ausdrücklich erwünscht (`ä`, `ö`, `ü`, `Ä`, `Ö`, `Ü`).
- Keine Umschreibungen mit `ae`, `oe`, `ue` in sichtbaren deutschen Texten.
## Design-Quelle
- Maßgeblich ist `GUI/style.md`. Dort liegen Farbpalette, Typografie, Spacing, Radien, Schatten, Komponentenregeln, Motion- und Responsive-Standards.
- Die App ist **light-first**, dateiorientiert und nutzt **Grün als kontrollierte Markenfarbe** statt Dauerakzent.
- Der modernisierte Look (siehe `GUI/style.md` → „Modernisierungs-Direktive") darf hochwertiger und visuell präziser wirken, bleibt aber ruhig, klar und produktiv kein Gaming-, Glassmorphism- oder Marketing-Look.
## Globale CSS-Basis
- `GUI/src/global.css` hält alle Design-Tokens (`:root`/`[data-theme='dark']`), Basis-Resets, Vuetify-Anpassungen und wiederverwendbare Patterns (`ui-panel`, `ui-toolbar`, `ui-list-row`, `ui-empty-state`, `ui-status`, …).
- `GUI/src/styles/global/page-layouts.css` enthält Page-Shells (`ui-page`, `ui-page--centered`, `ui-shell-grid`).
- `GUI/src/styles/global/surface-patterns.css` enthält wiederkehrende Surface-/Inhaltsbausteine (`ui-kicker`, `ui-action-row`, `ui-panel-gradient`, `ui-chip`, `ui-spotlight` …).
- Alle drei werden in `GUI/src/main.ts` einmalig importiert.
- Neue Layouts/Patterns immer **zuerst** dort ergänzen, nicht in `scoped`-Styles duplizieren.
## Anleitung: CSS-Patterns verwenden
- Neue Seiten standardmäßig mit `ui-page` aufbauen; für zentrierte Vollhöhen-Ansichten zusätzlich `ui-page--centered`.
- Karten-/Shell-Container als `ui-shell-grid ui-panel` verwenden; Breite/Abstände pro Seite über CSS-Variablen setzen (`--ui-shell-width`, `--ui-shell-gap`, `--ui-shell-padding`).
- Wiederkehrende Headlines/Kicker mit `ui-kicker` (+ ggf. `--wide`/`--xs`).
- Aktionszeilen mit `ui-action-row` bauen statt eigene Flex-Definitionen pro Seite.
- Gradient-Flächen über `ui-panel-gradient` + Variablen steuern.
- Lokales `scoped` CSS nur für wirklich seitenspezifische Sonderfälle.
## Aktueller Stand (Identity-Branch)
- `GUI/src/Layout.vue` ist die zentrale App-Shell: Topbar, Sidebar, Footer, responsiver Drawer, globaler Banner-Stack.
- Light-/Dark-Mode global integriert (Toggle in der Topbar, Persistenz in `localStorage`, Theme-Tokens in CSS und Vuetify).
- Öffentliche Kernseiten im Hoard-Stil: `Home.vue` (Landing), `Login.vue`, `404NotFound.vue`, `Impressum.vue`, `Forbidden.vue`.
- Geschützter Bereich: `Dashboard.vue`, `ChangePassword.vue`, `AdminUsers.vue`, `AdminUserDetail.vue`.
- Sidebar berücksichtigt Auth-/Rollen-Status; Admin-Bereich ist visuell abgesetzt.
- Mobile Touch-Optimierung (Safe-Area, 44/48px Mindestgrößen, Bottom-Drawer) ist aktiv.
- Backend-API: Health (`GET /api/health`), Auth (`POST /auth/login`, `POST /auth/logout`, `GET /auth/me`, `POST /auth/password`), Admin-User (`GET /auth/user`, `GET /auth/user/{id}`).
- Swagger nur in Development (`/swagger`).
- Frontend-Build (`npm run build` in `GUI`) schreibt nach `API/wwwroot`; das Backend liefert SPA + statische Assets.
- PostgreSQL via `ConnectionStrings:Postgres`, EF Core, automatische Migrationen beim Start.
- ASP.NET Identity mit `AppUser` (Guid-Key, `IsActive`, `MustChangePassword`, `CreatedAt`, `UpdatedAt`); Admin-Rechte über Rolle `admin`.
- `IdentitySeedService` legt nach Migrationen die `admin`-Rolle an und sichert einen initialen Admin-Account.
- Lokale Entwicklung: `API/Dev/docker-compose.yml` mit PostgreSQL (`localhost:5432`) und pgAdmin (`localhost:5050`).
- API lädt optional `API/appsettings.custom.json` (in `.gitignore`).
- Strukturierte Console-/HTTP-Logs aktiv.
- Frontend-Auth: Login → `/auth/login`, Dashboard auf `/`, Router-Guards prüfen Auth/Rollen, erzwingen Passwortwechsel bei `mustChangePassword=true`.
- Public Landingpage liegt auf `/welcome`; 404/Impressum referenzieren entsprechend.
- Topbar zeigt User-Menü (Dashboard, Passwort ändern, Abmelden).
- Globaler Banner-Stack am unteren Rand (stapelbar, manuell schließbar, opake Hintergründe, kontrolliertes `z-index`).
## Konventionen für Beitragende (auch Claude)
- **Erst Patterns, dann Custom-CSS:** Globale Klassen aus `global.css`/`page-layouts.css`/`surface-patterns.css` nutzen, bevor neue Stile lokal entstehen.
- **Tokens statt Hex:** Farben/Spacings/Radien immer aus CSS-Variablen ziehen, damit Light-/Dark-Mode automatisch funktionieren.
- **Mobile QA Pflicht** auf `360x800`, `390x844`, `768x1024`, `1024x768` und `>=1280`. Light- und Dark-Mode jeweils mindestens auf Desktop und Mobile prüfen.
- **Animationen:** kurz (160280 ms), `prefers-reduced-motion` immer respektieren.
- **API-Kontrakte stabil halten:** Frontend-Refactorings dürfen Backend-Endpunkte oder Auth-Flow nicht brechen.
## Befehle
```bash
# Frontend dev
cd GUI && npm install && npm run dev
# Frontend build (geht nach API/wwwroot)
cd GUI && npm run build
# Type-Check ohne Build
cd GUI && npm run type-check
# Lokale Datenbank
cd API/Dev && docker compose up -d
```
## Projektbeschreibung für eine KI
Hoard ist eine self-hosted Web-App für mehrere Benutzer, die eine Google-Drive-artige Dateiverwaltung mit einfacher Markdown-Bearbeitung kombiniert. Benutzer navigieren durch Ordner und Dateien, sehen Bilder und PDFs in einer Vorschau und bearbeiten Markdown direkt im Browser. Es gibt keine offene Registrierung, kein Teilen, keine Suche und keine Versionierung. Benutzerkonten werden manuell angelegt, beginnend mit einem initialen Admin-Account. Tech-Stack: Vue 3 (TypeScript, Vuetify, Pinia) im Frontend, ASP.NET Core mit C# im Backend, PostgreSQL für Metadaten, MinIO als S3-kompatibler Dateispeicher, Cookie-basierte Authentifizierung. Design: light-first, dateiorientiert, ruhig, mit kontrolliertem Grün als Markenfarbe modernisiert und visuell präzise, aber bewusst nicht spektakulär.
+10 -6
View File
@@ -1,10 +1,14 @@
<!DOCTYPE html> <!doctype html>
<html lang=""> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="theme-color" content="#1c652f" />
<title>Vite App</title> <meta name="description" content="Hoard self-hosted Datei- und Markdown-Workspace im Browser." />
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<title>Hoard · Self-hosted Workspace</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+728 -170
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,407 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
CreateAdminUserError,
FORBIDDEN_NOT_ADMIN_MESSAGE,
createAdminUser,
type AdminUser,
type CreateAdminUserPayload,
} from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
interface Props {
modelValue: boolean
}
defineProps<Props>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'created', user: AdminUser): void
}>()
const route = useRoute()
const router = useRouter()
const appBannersStore = useAppBannersStore()
const formUserName = ref('')
const formStartPassword = ref('')
const formStartPasswordConfirm = ref('')
const formIsAdmin = ref(false)
const formIsActive = ref(true)
const showPassword = ref(false)
const isSubmitting = ref(false)
const errorMessage = ref('')
const passwordIssues = ref<string[]>([])
const userNameError = ref('')
const passwordError = ref('')
const passwordConfirmError = ref('')
function resetForm() {
formUserName.value = ''
formStartPassword.value = ''
formStartPasswordConfirm.value = ''
formIsAdmin.value = false
formIsActive.value = true
showPassword.value = false
isSubmitting.value = false
errorMessage.value = ''
passwordIssues.value = []
userNameError.value = ''
passwordError.value = ''
passwordConfirmError.value = ''
}
watch(
() => route.fullPath,
() => {
// safety: dialog should not survive a route change
},
)
function open(value: boolean) {
if (value) {
resetForm()
}
emit('update:modelValue', value)
}
const trimmedUserName = computed(() => formUserName.value.trim())
const passwordsMatch = computed(
() => formStartPassword.value.length > 0 && formStartPassword.value === formStartPasswordConfirm.value,
)
const passwordHints = computed(() => [
{ label: 'Mindestens 8 Zeichen', valid: formStartPassword.value.length >= 8 },
{ label: 'Buchstabe enthalten', valid: /[A-Za-zÄÖÜäöüß]/.test(formStartPassword.value) },
{ label: 'Ziffer enthalten', valid: /\d/.test(formStartPassword.value) },
{ label: 'Übereinstimmung mit Bestätigung', valid: passwordsMatch.value },
])
const canSubmit = computed(() => {
return (
!isSubmitting.value &&
trimmedUserName.value.length > 0 &&
formStartPassword.value.length > 0 &&
passwordsMatch.value
)
})
function clearFieldErrors(field: 'user' | 'password' | 'confirm') {
if (field === 'user' && userNameError.value) userNameError.value = ''
if (field === 'password') {
if (passwordError.value) passwordError.value = ''
if (passwordIssues.value.length > 0) passwordIssues.value = []
}
if (field === 'confirm' && passwordConfirmError.value) passwordConfirmError.value = ''
}
function close() {
if (isSubmitting.value) {
return
}
emit('update:modelValue', false)
}
async function submit() {
if (!canSubmit.value) {
if (trimmedUserName.value.length === 0) {
userNameError.value = 'Benutzername darf nicht leer sein.'
}
if (formStartPassword.value.length === 0) {
passwordError.value = 'Startpasswort darf nicht leer sein.'
} else if (!passwordsMatch.value) {
passwordConfirmError.value = 'Passwörter stimmen nicht überein.'
}
return
}
errorMessage.value = ''
passwordIssues.value = []
userNameError.value = ''
passwordError.value = ''
passwordConfirmError.value = ''
const payload: CreateAdminUserPayload = {
userName: trimmedUserName.value,
startPassword: formStartPassword.value,
isAdmin: formIsAdmin.value,
isActive: formIsActive.value,
}
isSubmitting.value = true
try {
const created = await createAdminUser(payload)
emit('created', created)
appBannersStore.push({
type: 'success',
message: `Benutzer „${created.userName}" wurde angelegt.`,
})
emit('update:modelValue', false)
await router.push({ name: 'AdminUserDetail', params: { userId: created.id } })
} catch (error) {
if (error instanceof CreateAdminUserError) {
errorMessage.value = error.message
if (error.status === 409) {
userNameError.value = error.message
} else if (error.status === 422) {
passwordIssues.value = error.fieldErrors
passwordError.value = error.message
} else if (error.status === 400) {
passwordIssues.value = error.fieldErrors
}
} else if (error instanceof AuthRequestError) {
if (error.status === 401) {
emit('update:modelValue', false)
await router.replace({
name: 'Login',
query: { redirect: route.fullPath },
})
return
}
if (error.status === 403 && error.message === FORBIDDEN_NOT_ADMIN_MESSAGE) {
emit('update:modelValue', false)
await router.replace({ name: 'Forbidden' })
return
}
errorMessage.value = error.message
} else {
errorMessage.value = 'Benutzer konnte nicht angelegt werden.'
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="560"
persistent
@update:model-value="(value: boolean) => open(value)"
>
<v-card class="create-user-dialog ui-panel">
<v-card-title class="create-user-dialog__title">
<p class="ui-kicker ui-kicker--xs">Adminbereich</p>
<h2>Neuen Benutzer anlegen</h2>
</v-card-title>
<v-card-text class="create-user-dialog__body">
<v-form @submit.prevent="submit">
<v-text-field
v-model="formUserName"
label="Benutzername"
prepend-inner-icon="mdi-account-outline"
autocomplete="off"
:error="!!userNameError"
:error-messages="userNameError"
:disabled="isSubmitting"
@update:model-value="() => clearFieldErrors('user')"
/>
<v-text-field
v-model="formStartPassword"
label="Startpasswort"
prepend-inner-icon="mdi-lock-outline"
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
:error="!!passwordError"
:error-messages="passwordError"
:disabled="isSubmitting"
@click:append-inner="showPassword = !showPassword"
@update:model-value="() => clearFieldErrors('password')"
/>
<v-text-field
v-model="formStartPasswordConfirm"
label="Startpasswort bestätigen"
prepend-inner-icon="mdi-lock-check-outline"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
:error="!!passwordConfirmError"
:error-messages="passwordConfirmError"
:disabled="isSubmitting"
@update:model-value="() => clearFieldErrors('confirm')"
/>
<ul class="password-hints">
<li
v-for="hint in passwordHints"
:key="hint.label"
:class="['password-hint', { 'password-hint--valid': hint.valid }]"
>
<v-icon :icon="hint.valid ? 'mdi-check-circle' : 'mdi-circle-outline'" size="14" />
{{ hint.label }}
</li>
</ul>
<p class="create-user-dialog__hint">
<v-icon size="16" icon="mdi-information-outline" />
Der Benutzer muss das Passwort beim ersten Login ändern.
</p>
<v-switch
v-model="formIsActive"
color="primary"
hide-details
:label="formIsActive ? 'Konto ist aktiv' : 'Konto ist inaktiv'"
:disabled="isSubmitting"
/>
<v-switch
v-model="formIsAdmin"
color="primary"
hide-details
:label="formIsAdmin ? 'Adminrechte werden vergeben' : 'Standardbenutzer ohne Adminrechte'"
:disabled="isSubmitting"
/>
<v-alert
v-if="passwordIssues.length > 0"
type="warning"
density="comfortable"
class="create-user-dialog__alert"
>
<p class="create-user-dialog__alert-title">Passwort erfüllt die Anforderungen nicht:</p>
<ul class="create-user-dialog__alert-list">
<li v-for="(issue, index) in passwordIssues" :key="index">{{ issue }}</li>
</ul>
</v-alert>
<v-alert
v-else-if="errorMessage"
type="error"
density="comfortable"
class="create-user-dialog__alert"
>
{{ errorMessage }}
</v-alert>
</v-form>
</v-card-text>
<v-card-actions class="create-user-dialog__actions">
<v-btn variant="text" :disabled="isSubmitting" @click="close">
Abbrechen
</v-btn>
<v-btn
variant="elevated"
color="primary"
prepend-icon="mdi-account-plus-outline"
:loading="isSubmitting"
:disabled="!canSubmit"
@click="submit"
>
Anlegen
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.create-user-dialog {
padding: var(--space-2);
}
.create-user-dialog__title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-5) var(--space-6) 0;
}
.create-user-dialog__title h2 {
margin: 0;
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
}
.create-user-dialog__body {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
}
.create-user-dialog__body :deep(.v-form) {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.create-user-dialog__hint {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.create-user-dialog__alert {
margin-top: var(--space-1);
}
.create-user-dialog__alert-title {
margin: 0 0 var(--space-2);
font-weight: 600;
}
.create-user-dialog__alert-list {
margin: 0;
padding-left: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.create-user-dialog__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: 0 var(--space-6) var(--space-5);
}
.password-hints {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
padding: 0;
margin: 0;
list-style: none;
}
.password-hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.3;
transition: color var(--transition-fast);
}
.password-hint--valid {
color: var(--color-primary-700);
}
.password-hint .v-icon {
flex: 0 0 auto;
}
@media (width <= 600px) {
.password-hints {
grid-template-columns: 1fr;
}
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
FORBIDDEN_NOT_ADMIN_MESSAGE,
updateAdminUser,
type AdminUser,
type UpdateAdminUserPayload,
} from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
interface Props {
modelValue: boolean
user: AdminUser
}
const props = defineProps<Props>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'updated', user: AdminUser): void
}>()
const route = useRoute()
const router = useRouter()
const appBannersStore = useAppBannersStore()
const formUserName = ref(props.user.userName)
const formIsActive = ref(props.user.isActive)
const formMustChangePassword = ref(props.user.mustChangePassword)
const isSubmitting = ref(false)
const errorMessage = ref('')
const userNameError = ref('')
watch(
() => props.modelValue,
(open, prevOpen) => {
if (open && !prevOpen) {
formUserName.value = props.user.userName
formIsActive.value = props.user.isActive
formMustChangePassword.value = props.user.mustChangePassword
errorMessage.value = ''
userNameError.value = ''
isSubmitting.value = false
}
},
)
const isAdmin = computed(() => props.user.roles.some((role) => role.toLowerCase() === 'admin'))
const trimmedUserName = computed(() => formUserName.value.trim())
const hasChanges = computed(() => {
return (
trimmedUserName.value !== props.user.userName ||
formIsActive.value !== props.user.isActive ||
formMustChangePassword.value !== props.user.mustChangePassword
)
})
const showDeactivationHint = computed(() => {
return props.user.isActive && !formIsActive.value
})
function close() {
if (isSubmitting.value) {
return
}
emit('update:modelValue', false)
}
function clearUserNameErrorOnEdit() {
if (userNameError.value) {
userNameError.value = ''
}
}
async function submit() {
if (isSubmitting.value || !hasChanges.value) {
return
}
errorMessage.value = ''
userNameError.value = ''
const payload: UpdateAdminUserPayload = {}
if (trimmedUserName.value !== props.user.userName) {
if (trimmedUserName.value.length === 0) {
userNameError.value = 'Benutzername darf nicht leer sein.'
return
}
payload.userName = trimmedUserName.value
}
if (formIsActive.value !== props.user.isActive) {
payload.isActive = formIsActive.value
}
if (formMustChangePassword.value !== props.user.mustChangePassword) {
payload.mustChangePassword = formMustChangePassword.value
}
isSubmitting.value = true
try {
const updated = await updateAdminUser(props.user.id, payload)
emit('updated', updated)
appBannersStore.push({
type: 'success',
message: 'Benutzer wurde aktualisiert.',
})
emit('update:modelValue', false)
} catch (error) {
if (error instanceof AuthRequestError) {
if (error.status === 401) {
emit('update:modelValue', false)
await router.replace({
name: 'Login',
query: { redirect: route.fullPath },
})
return
}
if (error.status === 403 && error.message === FORBIDDEN_NOT_ADMIN_MESSAGE) {
emit('update:modelValue', false)
await router.replace({ name: 'Forbidden' })
return
}
errorMessage.value = error.message
if (error.status === 400 && /leer/i.test(error.message)) {
userNameError.value = error.message
}
} else {
errorMessage.value = 'Benutzer konnte nicht aktualisiert werden.'
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="520"
persistent
@update:model-value="(value: boolean) => emit('update:modelValue', value)"
>
<v-card class="edit-user-dialog ui-panel">
<v-card-title class="edit-user-dialog__title">
<p class="ui-kicker ui-kicker--xs">Adminbereich</p>
<h2>Benutzer bearbeiten</h2>
</v-card-title>
<v-card-text class="edit-user-dialog__body">
<v-form @submit.prevent="submit">
<v-text-field
v-model="formUserName"
label="Benutzername"
prepend-inner-icon="mdi-account-outline"
autocomplete="off"
:error="!!userNameError"
:error-messages="userNameError"
:disabled="isSubmitting"
@update:model-value="clearUserNameErrorOnEdit"
/>
<v-switch
v-model="formIsActive"
color="primary"
hide-details
:label="formIsActive ? 'Konto ist aktiv' : 'Konto ist inaktiv'"
:disabled="isSubmitting || isAdmin"
/>
<v-switch
v-model="formMustChangePassword"
color="primary"
hide-details
:label="formMustChangePassword ? 'Passwortwechsel beim nächsten Login erzwingen' : 'Passwort kann normal verwendet werden'"
:disabled="isSubmitting"
/>
<p v-if="isAdmin" class="edit-user-dialog__hint">
<v-icon size="16" icon="mdi-shield-lock-outline" />
Adminkonten können nicht deaktiviert werden.
</p>
<p v-else-if="showDeactivationHint" class="edit-user-dialog__hint">
<v-icon size="16" icon="mdi-information-outline" />
Beim Deaktivieren werden bestehende Sessions des Nutzers beendet.
</p>
<v-alert
v-if="errorMessage"
type="error"
density="comfortable"
class="edit-user-dialog__alert"
>
{{ errorMessage }}
</v-alert>
</v-form>
</v-card-text>
<v-card-actions class="edit-user-dialog__actions">
<v-btn variant="text" :disabled="isSubmitting" @click="close">
Abbrechen
</v-btn>
<v-btn
variant="elevated"
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="isSubmitting"
:disabled="!hasChanges || isSubmitting"
@click="submit"
>
Speichern
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.edit-user-dialog {
padding: var(--space-2);
}
.edit-user-dialog__title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-5) var(--space-6) 0;
}
.edit-user-dialog__title h2 {
margin: 0;
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
}
.edit-user-dialog__body {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
}
.edit-user-dialog__body :deep(.v-form) {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.edit-user-dialog__hint {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.edit-user-dialog__alert {
margin-top: var(--space-1);
}
.edit-user-dialog__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: 0 var(--space-6) var(--space-5);
}
</style>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
icon: string
title: string
}>()
</script>
<template>
<section class="ui-panel ui-empty-state">
<span class="ui-icon-tile ui-icon-tile--lg">
<v-icon :icon="icon" size="22" />
</span>
<h2>{{ title }}</h2>
<p><slot /></p>
</section>
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
variant: 'success' | 'danger' | 'warning' | 'info' | 'muted'
}>()
</script>
<template>
<span :class="['ui-status', `ui-status--${variant}`]">
<slot />
</span>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{
initials: string
}>()
</script>
<template>
<span>{{ initials }}</span>
</template>
+98
View File
@@ -0,0 +1,98 @@
import { computed, type Ref } from 'vue'
import { useRoute } from 'vue-router'
import { Visibility, routes, type LayoutRoute } from '@/plugins/routesLayout'
import { ROLE_ADMIN, hasRole, type CurrentUser } from '@/services/authSession'
function normalizeRoutePath(path: string) {
if (!path || path === '/') {
return '/'
}
return path.endsWith('/') ? path.slice(0, -1) : path
}
function isWithinRoutePath(currentPath: string, targetPath: string) {
const normalizedCurrent = normalizeRoutePath(currentPath)
const normalizedTarget = normalizeRoutePath(targetPath)
if (normalizedTarget === '/') {
return normalizedCurrent === '/'
}
return (
normalizedCurrent === normalizedTarget ||
normalizedCurrent.startsWith(`${normalizedTarget}/`)
)
}
function resolveVisibilityRoutes(path: string, visibilityRoute?: string | string[]) {
if (Array.isArray(visibilityRoute)) {
return visibilityRoute.map((entry) => entry.trim()).filter((entry) => entry.length > 0)
}
const normalized = visibilityRoute?.trim()
if (normalized && normalized.length > 0) {
return [normalized]
}
return [path]
}
function isVisible(item: LayoutRoute, currentPath: string, user: CurrentUser | null) {
switch (item.visible) {
case Visibility.Public:
return true
case Visibility.Authenticated:
return user !== null
case Visibility.Unauthenticated:
return user === null
case Visibility.Authorized:
if (!user) {
return false
}
if (!item.requiredRoles || item.requiredRoles.length === 0) {
return true
}
return item.requiredRoles.every((role) => hasRole(user, role))
case Visibility.Route:
return resolveVisibilityRoutes(item.path, item.visibilityRoute).some((target) =>
isWithinRoutePath(currentPath, target),
)
default:
return false
}
}
function isAdminRoute(item: LayoutRoute) {
return (
item.visible === Visibility.Authorized &&
Array.isArray(item.requiredRoles) &&
item.requiredRoles.some((role) => role.trim().toLowerCase() === ROLE_ADMIN)
)
}
export function useSidebarRoutes(currentUser: Ref<CurrentUser | null>) {
const route = useRoute()
const sidebarRoutes = computed(() =>
routes.filter((item) => isVisible(item, route.path, currentUser.value)),
)
const adminSidebarRoutes = computed(() => sidebarRoutes.value.filter(isAdminRoute))
const primarySidebarRoutes = computed(() =>
sidebarRoutes.value.filter((item) => !isAdminRoute(item)),
)
const footerRoutes = computed(() =>
routes.filter((item) => item.visible === Visibility.Footer),
)
return {
sidebarRoutes,
adminSidebarRoutes,
primarySidebarRoutes,
footerRoutes,
}
}
+436 -103
View File
@@ -1,83 +1,143 @@
/* =============================================================================
Hoard Globale Tokens, Resets und Vuetify-Anpassungen
Quelle: GUI/style.md
============================================================================= */
:root { :root {
color-scheme: light; color-scheme: light;
--color-bg: #f6f8f5; /* Surfaces */
--color-bg: #f5f8f2;
--color-bg-tint: #eef3ea;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-surface-alt: #f1f4ef; --color-surface-alt: #f1f4ee;
--color-border: #dce4d8; --color-surface-elevated: #fbfcf8;
--color-border-strong: #c7d2c2;
--color-text: #1f2a21; --color-border: #dce3d6;
--color-text-secondary: #5f6e62; --color-border-strong: #c5cfbe;
--color-text-muted: #7d8a80; --color-border-subtle: #e8ede2;
/* Text */
--color-text: #1a2a1e;
--color-text-secondary: #5a6a5e;
--color-text-muted: #7b897f;
--color-text-on-primary: #ffffff; --color-text-on-primary: #ffffff;
/* Brand */
--color-primary-800: #10421e;
--color-primary-700: #1c652f; --color-primary-700: #1c652f;
--color-primary-600: #2e7d32; --color-primary-600: #2e7d32;
--color-primary-500: #3c8f42; --color-primary-500: #3c8f42;
--color-primary-300: #a8d5a2; --color-primary-300: #a8d5a2;
--color-primary-100: #eaf5e8; --color-primary-100: #eaf5e8;
--color-primary-050: #f4faf1;
--color-accent-lime: #b7e36b; --color-accent-lime: #b7e36b;
/* Status */
--color-success: #2e7d32; --color-success: #2e7d32;
--color-warning: #b7791f; --color-warning: #b7791f;
--color-danger: #c0392b; --color-danger: #c0392b;
--color-info: #2f6fb3; --color-info: #2f6fb3;
--radius-sm: 8px; /* Radius */
--radius-md: 10px; --radius-xs: 6px;
--radius-lg: 14px; --radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--radius-xl: 22px;
--radius-full: 999px;
--shadow-sm: 0 1px 2px rgb(16 24 18 / 6%); /* Shadows */
--shadow-md: 0 6px 18px rgb(16 24 18 / 8%); --shadow-xs: 0 1px 1px rgb(20 30 22 / 4%);
--shadow-sm: 0 2px 6px rgb(20 30 22 / 6%);
--shadow-md: 0 8px 22px rgb(20 30 22 / 8%);
--shadow-lg: 0 18px 44px rgb(20 30 22 / 12%);
--shadow-glow: 0 12px 36px rgb(28 101 47 / 18%);
/* Spacing */
--space-1: 4px; --space-1: 4px;
--space-2: 8px; --space-2: 8px;
--space-3: 12px; --space-3: 12px;
--space-4: 16px; --space-4: 16px;
--space-5: 20px; --space-5: 20px;
--space-6: 24px; --space-6: 24px;
--space-7: 28px;
--space-8: 32px; --space-8: 32px;
--space-10: 40px; --space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Typography */
--font-family-sans: --font-family-sans:
Inter, 'Segoe UI', Roboto, system-ui, -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Inter', 'Segoe UI', Roboto, system-ui, -apple-system, BlinkMacSystemFont,
Arial, sans-serif; 'Helvetica Neue', Arial, sans-serif;
--font-family-mono:
'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo,
'SF Mono', Consolas, 'Liberation Mono', monospace;
--font-size-2xs: 11px;
--font-size-xs: 12px; --font-size-xs: 12px;
--font-size-sm: 13px; --font-size-sm: 13px;
--font-size-md: 14px; --font-size-md: 14px;
--font-size-lg: 18px; --font-size-lg: 16px;
--font-size-xl: 24px; --font-size-xl: 20px;
--font-size-2xl: 26px;
--font-size-3xl: 32px;
--font-size-display: 44px;
--line-height-tight: 1.3; --line-height-tight: 1.2;
--line-height-normal: 1.5; --line-height-normal: 1.5;
--line-height-loose: 1.65; --line-height-loose: 1.65;
--transition-fast: 160ms ease; /* Motion */
--page-bg-glow: rgb(183 227 107 / 14%); --transition-fast: 160ms cubic-bezier(0.2, 0, 0, 1);
--scrollbar-thumb: #a6b3a2; --transition-medium: 220ms cubic-bezier(0.2, 0, 0, 1);
--scrollbar-track: #e8ede5; --transition-slow: 320ms cubic-bezier(0.2, 0, 0, 1);
/* Atmospheric helpers */
--page-bg-ambient:
radial-gradient(
90% 60% at 12% -10%,
color-mix(in srgb, var(--color-primary-100) 75%, transparent) 0%,
transparent 60%
),
radial-gradient(
80% 60% at 100% 0%,
color-mix(in srgb, var(--color-accent-lime) 12%, transparent) 0%,
transparent 55%
),
linear-gradient(180deg, var(--color-bg-tint) 0%, var(--color-bg) 360px);
--scrollbar-thumb: #b1bcab;
--scrollbar-thumb-hover: #95a290;
--scrollbar-track: transparent;
} }
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--color-bg: #101215; --color-bg: #0e1115;
--color-surface: #171a1f; --color-bg-tint: #11161c;
--color-surface-alt: #1d2229; --color-surface: #161a20;
--color-border: #2c333d; --color-surface-alt: #1b2028;
--color-border-strong: #3a4350; --color-surface-elevated: #1f252e;
--color-text: #ebeff3; --color-border: #2a323d;
--color-text-secondary: #c5ccd4; --color-border-strong: #3a4452;
--color-text-muted: #95a0ad; --color-border-subtle: #232a34;
--color-text: #e9eff3;
--color-text-secondary: #b6bec8;
--color-text-muted: #8b94a0;
--color-text-on-primary: #08120a; --color-text-on-primary: #08120a;
--color-primary-800: #8be194;
--color-primary-700: #5fb968; --color-primary-700: #5fb968;
--color-primary-600: #4ea758; --color-primary-600: #4ea758;
--color-primary-500: #3f9148; --color-primary-500: #3f9148;
--color-primary-300: #2e6a37; --color-primary-300: #2e6a37;
--color-primary-100: #202822; --color-primary-100: #1f2922;
--color-primary-050: #161e19;
--color-accent-lime: #b7e36b; --color-accent-lime: #b7e36b;
--color-success: #5fb968; --color-success: #5fb968;
@@ -85,14 +145,30 @@
--color-danger: #e07a7a; --color-danger: #e07a7a;
--color-info: #6aa8de; --color-info: #6aa8de;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 35%); --shadow-xs: 0 1px 2px rgb(0 0 0 / 30%);
--shadow-md: 0 6px 18px rgb(0 0 0 / 40%); --shadow-sm: 0 2px 6px rgb(0 0 0 / 35%);
--shadow-md: 0 10px 26px rgb(0 0 0 / 40%);
--shadow-lg: 0 18px 44px rgb(0 0 0 / 48%);
--shadow-glow: 0 14px 40px rgb(95 185 104 / 22%);
--page-bg-glow: rgb(79 145 72 / 7%); --page-bg-ambient:
--scrollbar-thumb: #4f5763; radial-gradient(
--scrollbar-track: #171b22; 90% 60% at 12% -10%,
color-mix(in srgb, var(--color-primary-700) 18%, transparent) 0%,
transparent 60%
),
radial-gradient(
80% 60% at 100% 0%,
color-mix(in srgb, var(--color-accent-lime) 6%, transparent) 0%,
transparent 55%
),
linear-gradient(180deg, var(--color-bg-tint) 0%, var(--color-bg) 360px);
--scrollbar-thumb: #404a57;
--scrollbar-thumb-hover: #5a6573;
} }
/* ---------- Reset / base ---------- */
*, *,
*::before, *::before,
*::after { *::after {
@@ -116,14 +192,15 @@ body {
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-md); font-size: var(--font-size-md);
color: var(--color-text); color: var(--color-text);
background: radial-gradient(circle at top right, var(--page-bg-glow), transparent 36%), var(--color-bg); background: var(--page-bg-ambient);
background-attachment: fixed;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow-y: scroll; overflow-y: scroll;
} }
::selection { ::selection {
background-color: color-mix(in srgb, var(--color-primary-300) 38%, transparent); background-color: color-mix(in srgb, var(--color-primary-300) 42%, transparent);
color: var(--color-text); color: var(--color-text);
} }
@@ -146,6 +223,13 @@ h6 {
margin: 0 0 var(--space-4); margin: 0 0 var(--space-4);
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
color: var(--color-text); color: var(--color-text);
font-weight: 600;
letter-spacing: -0.01em;
}
h1 {
font-weight: 700;
letter-spacing: -0.02em;
} }
p { p {
@@ -154,12 +238,18 @@ p {
line-height: var(--line-height-loose); line-height: var(--line-height-loose);
} }
code,
pre {
font-family: var(--font-family-mono);
}
:where(a, button, input, textarea, select, [tabindex]):focus-visible { :where(a, button, input, textarea, select, [tabindex]):focus-visible {
outline: 2px solid var(--color-primary-500); outline: 2px solid var(--color-primary-500);
outline-offset: 2px; outline-offset: 2px;
border-radius: var(--radius-xs);
} }
/* App shell */ /* ---------- App shell ---------- */
.v-application { .v-application {
font-family: var(--font-family-sans) !important; font-family: var(--font-family-sans) !important;
color: var(--color-text) !important; color: var(--color-text) !important;
@@ -171,43 +261,89 @@ p {
} }
.v-app-bar { .v-app-bar {
background-color: var(--color-surface) !important; background-color: color-mix(in srgb, var(--color-surface) 92%, transparent) !important;
backdrop-filter: saturate(120%) blur(8px);
border-bottom: 1px solid var(--color-border) !important; border-bottom: 1px solid var(--color-border) !important;
box-shadow: var(--shadow-sm) !important; box-shadow: var(--shadow-xs) !important;
} }
.v-navigation-drawer { .v-navigation-drawer {
background-color: var(--color-surface-alt) !important; background-color: var(--color-surface-alt) !important;
border-right: 1px solid var(--color-border) !important; border-right: 1px solid var(--color-border) !important;
background-image: linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface-alt) 96%, var(--color-primary-100) 4%),
var(--color-surface-alt) 220px
);
} }
.v-navigation-drawer .v-list-item { .v-navigation-drawer .v-list-item {
position: relative;
border-radius: var(--radius-md) !important; border-radius: var(--radius-md) !important;
margin: 2px var(--space-2); margin: 2px var(--space-2);
min-height: 44px;
transition:
background-color var(--transition-fast),
color var(--transition-fast);
}
.v-navigation-drawer .v-list-item::before {
content: '';
position: absolute;
left: 4px;
top: 50%;
width: 3px;
height: 0;
background-color: var(--color-primary-600);
border-radius: var(--radius-full);
transform: translateY(-50%);
transition: height var(--transition-medium);
} }
.v-navigation-drawer .v-list-item:hover { .v-navigation-drawer .v-list-item:hover {
background-color: color-mix(in srgb, var(--color-primary-100) 45%, var(--color-surface-alt) 55%) !important; background-color: color-mix(
in srgb,
var(--color-primary-100) 50%,
var(--color-surface-alt) 50%
) !important;
} }
.v-navigation-drawer .v-list-item--active { .v-navigation-drawer .v-list-item--active {
color: var(--color-primary-700) !important; color: var(--color-primary-700) !important;
background-color: var(--color-primary-100) !important; background-color: color-mix(
in srgb,
var(--color-primary-100) 75%,
var(--color-surface) 25%
) !important;
font-weight: 600; font-weight: 600;
} }
/* Surface components */ .v-navigation-drawer .v-list-item--active::before {
height: 60%;
}
/* ---------- Cards / surfaces ---------- */
.v-card { .v-card {
border: 1px solid var(--color-border) !important; border: 1px solid var(--color-border) !important;
border-radius: var(--radius-lg) !important; border-radius: var(--radius-lg) !important;
background-color: var(--color-surface) !important; background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 96%, var(--color-surface-alt) 4%),
var(--color-surface)
) !important;
box-shadow: var(--shadow-sm) !important; box-shadow: var(--shadow-sm) !important;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
} }
.v-card-title { .v-card-title {
color: var(--color-text) !important; color: var(--color-text) !important;
font-size: var(--font-size-lg) !important; font-size: var(--font-size-xl) !important;
font-weight: 600 !important; font-weight: 600 !important;
letter-spacing: -0.01em;
} }
.v-card-text { .v-card-text {
@@ -216,45 +352,100 @@ p {
.v-footer { .v-footer {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
background-color: color-mix(in srgb, var(--color-surface) 90%, var(--color-bg) 10%) !important; background-color: color-mix(in srgb, var(--color-surface) 92%, var(--color-bg) 8%) !important;
} }
/* Buttons */ /* ---------- Buttons ---------- */
.v-btn { .v-btn {
letter-spacing: 0 !important; letter-spacing: 0 !important;
text-transform: none !important; text-transform: none !important;
border-radius: var(--radius-md) !important; border-radius: var(--radius-sm) !important;
font-weight: 500 !important; font-weight: 500 !important;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
color var(--transition-fast),
transform var(--transition-fast) !important;
} }
.v-btn--variant-elevated, .v-btn--variant-elevated,
.v-btn--variant-flat { .v-btn--variant-flat {
box-shadow: none !important; box-shadow: var(--shadow-xs) !important;
} }
.v-btn--variant-elevated:not(.v-btn--disabled):hover, .v-btn--variant-elevated:not(.v-btn--disabled):hover,
.v-btn--variant-flat:not(.v-btn--disabled):hover { .v-btn--variant-flat:not(.v-btn--disabled):hover {
box-shadow: var(--shadow-sm) !important; box-shadow: var(--shadow-sm) !important;
transform: translateY(-1px);
} }
.v-btn.v-btn--variant-elevated:not(.v-btn--disabled) { .v-btn:not(.v-btn--disabled):active {
transform: translateY(0);
}
.v-btn.v-btn--variant-elevated:not(.v-btn--disabled),
.v-btn.v-btn--variant-flat:not(.v-btn--disabled) {
color: var(--color-text-on-primary) !important; color: var(--color-text-on-primary) !important;
background-color: var(--color-primary-700) !important; background:
linear-gradient(
180deg,
var(--color-primary-600),
var(--color-primary-700)
) !important;
} }
.v-btn.v-btn--variant-elevated:not(.v-btn--disabled):hover { .v-btn.v-btn--variant-elevated:not(.v-btn--disabled):hover,
background-color: var(--color-primary-600) !important; .v-btn.v-btn--variant-flat:not(.v-btn--disabled):hover {
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary-600) 90%, var(--color-accent-lime) 10%),
var(--color-primary-700)
) !important;
}
.v-btn.v-btn--disabled {
opacity: 1 !important;
color: var(--color-text-muted) !important;
background:
color-mix(in srgb, var(--color-surface-alt) 80%, var(--color-surface) 20%) !important;
border-color: var(--color-border) !important;
box-shadow: none !important;
} }
.v-btn--variant-outlined { .v-btn--variant-outlined {
border-color: var(--color-border-strong) !important; border-color: var(--color-border-strong) !important;
color: var(--color-text) !important; color: var(--color-text) !important;
background-color: color-mix(
in srgb,
var(--color-surface) 88%,
var(--color-surface-alt) 12%
) !important;
} }
/* Inputs */ .v-btn--variant-outlined:not(.v-btn--disabled):hover,
.v-btn--variant-text:not(.v-btn--disabled):hover {
background-color: color-mix(
in srgb,
var(--color-primary-100) 38%,
transparent
) !important;
border-color: color-mix(
in srgb,
var(--color-primary-600) 50%,
var(--color-border-strong) 50%
) !important;
color: var(--color-primary-700) !important;
}
/* ---------- Inputs ---------- */
.v-input .v-field { .v-input .v-field {
border-radius: var(--radius-md) !important; border-radius: var(--radius-md) !important;
background-color: var(--color-surface) !important; background-color: var(--color-surface) !important;
transition:
background-color var(--transition-fast),
box-shadow var(--transition-fast);
} }
.v-input .v-field__outline { .v-input .v-field__outline {
@@ -266,7 +457,10 @@ p {
color: var(--color-primary-600) !important; color: var(--color-primary-600) !important;
} }
/* Vuetify steuert den Fokuszustand bereits am Feld; verhindert doppelten Fokusrahmen im Input */ .v-input.v-input--focused .v-field {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary-300) 32%, transparent);
}
.v-input .v-field :is(input, textarea, select):focus-visible { .v-input .v-field :is(input, textarea, select):focus-visible {
outline: none !important; outline: none !important;
} }
@@ -275,109 +469,188 @@ p {
color: var(--color-text-secondary) !important; color: var(--color-text-secondary) !important;
} }
/* Tables and list-like content */ .v-overlay .v-list {
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-md) !important;
background-color: var(--color-surface-elevated) !important;
box-shadow: var(--shadow-lg) !important;
}
/* ---------- Tables ---------- */
.v-table { .v-table {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background-color: var(--color-surface) !important; background-color: var(--color-surface) !important;
overflow: hidden;
} }
.v-table thead th { .v-table thead th {
color: var(--color-text-secondary) !important; color: var(--color-text-secondary) !important;
font-weight: 600 !important; font-weight: 600 !important;
background-color: var(--color-surface-alt) !important; background-color: var(--color-surface-alt) !important;
font-size: var(--font-size-xs) !important;
letter-spacing: 0.06em;
text-transform: uppercase;
} }
.v-table tbody tr:hover td { .v-table tbody tr:hover td {
background-color: color-mix(in srgb, var(--color-primary-100) 35%, var(--color-surface) 65%) !important; background-color: color-mix(
in srgb,
var(--color-primary-100) 32%,
var(--color-surface) 68%
) !important;
} }
/* Status helpers */ .v-table tbody td {
.hoard-status { border-bottom-color: var(--color-border-subtle) !important;
}
/* ---------- Status pills ---------- */
.ui-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
padding: 2px var(--space-2); padding: 3px var(--space-2);
border-radius: var(--radius-sm); border-radius: var(--radius-xs);
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
font-weight: 500; font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
} }
.hoard-status--success { .ui-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background-color: currentcolor;
box-shadow: 0 0 0 3px color-mix(in srgb, currentcolor 22%, transparent);
}
.ui-status--success {
color: var(--color-success); color: var(--color-success);
background-color: color-mix(in srgb, var(--color-success) 14%, var(--color-surface) 86%); background-color: color-mix(in srgb, var(--color-success) 14%, var(--color-surface) 86%);
} }
.hoard-status--warning { .ui-status--warning {
color: var(--color-warning); color: var(--color-warning);
background-color: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface) 85%); background-color: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface) 84%);
} }
.hoard-status--danger { .ui-status--danger {
color: var(--color-danger); color: var(--color-danger);
background-color: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface) 88%); background-color: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface) 86%);
} }
.hoard-status--info { .ui-status--info {
color: var(--color-info); color: var(--color-info);
background-color: color-mix(in srgb, var(--color-info) 12%, var(--color-surface) 88%); background-color: color-mix(in srgb, var(--color-info) 14%, var(--color-surface) 86%);
} }
/* Reusable layout helpers for file/productivity pages */ .ui-status--muted {
.hoard-panel { color: var(--color-text-muted);
background-color: var(--color-surface); background-color: color-mix(in srgb, var(--color-surface-alt) 70%, var(--color-surface) 30%);
}
.ui-status--muted::before {
background-color: var(--color-text-muted);
}
/* ---------- Reusable surfaces ---------- */
.ui-panel {
position: relative;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 95%, var(--color-surface-alt) 5%),
var(--color-surface)
);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
} }
.hoard-toolbar { .ui-panel--elevated {
background-color: var(--color-surface-elevated);
box-shadow: var(--shadow-md);
}
.ui-panel--ghost {
background:
color-mix(in srgb, var(--color-surface-alt) 60%, var(--color-surface) 40%);
border-color: var(--color-border-subtle);
box-shadow: none;
}
.ui-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-alt); background-color: var(--color-surface-alt);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
} }
.hoard-list-row { .ui-list-row {
display: grid; display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(120px, 1fr) minmax(100px, 1fr) minmax(120px, 1fr); grid-template-columns: minmax(220px, 2fr) minmax(120px, 1fr) minmax(100px, 1fr) minmax(120px, 1fr);
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 85%, white 15%); border-bottom: 1px solid var(--color-border-subtle);
transition: background-color var(--transition-fast); transition:
background-color var(--transition-fast),
transform var(--transition-fast);
} }
.hoard-list-row:hover { .ui-list-row:hover {
background-color: color-mix(in srgb, var(--color-primary-100) 35%, var(--color-surface) 65%); background-color: color-mix(
in srgb,
var(--color-primary-100) 32%,
var(--color-surface) 68%
);
transform: translateX(2px);
} }
.hoard-list-row.is-selected { .ui-list-row.is-selected {
color: var(--color-primary-700); color: var(--color-primary-700);
background-color: var(--color-primary-100); background-color: color-mix(
in srgb,
var(--color-primary-100) 70%,
var(--color-surface) 30%
);
} }
.hoard-meta { .ui-meta {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.hoard-empty-state { .ui-empty-state {
padding: var(--space-8) var(--space-6); padding: var(--space-10) var(--space-6);
text-align: center; text-align: center;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.hoard-empty-state h2 { .ui-empty-state h2 {
margin-bottom: var(--space-3); margin-bottom: var(--space-2);
font-size: 20px; font-size: var(--font-size-xl);
font-weight: 600; font-weight: 600;
} }
/* Scrollbar refinement */ .ui-empty-state p {
margin: 0 auto;
max-width: 44ch;
}
/* ---------- Scrollbar ---------- */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
@@ -394,22 +667,70 @@ p {
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb); background: var(--scrollbar-thumb);
border-radius: 10px; border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: content-box;
} }
*::-webkit-scrollbar-thumb:hover { *::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--scrollbar-thumb) 85%, black 15%); background-color: var(--scrollbar-thumb-hover);
background-clip: content-box;
} }
/* ---------- Animations ---------- */
@keyframes ui-soft-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes ui-pulse-ring {
0% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary-300) 35%, transparent);
}
70% {
box-shadow: 0 0 0 12px color-mix(in srgb, var(--color-primary-300) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
.v-btn,
.v-navigation-drawer .v-list-item,
.ui-list-row {
transform: none !important;
}
}
/* ---------- Responsive ---------- */
@media (width <= 960px) { @media (width <= 960px) {
:root { :root {
--hoard-mobile-safe-left: max(var(--space-2), env(safe-area-inset-left)); --ui-mobile-safe-left: max(var(--space-2), env(safe-area-inset-left));
--hoard-mobile-safe-right: max(var(--space-2), env(safe-area-inset-right)); --ui-mobile-safe-right: max(var(--space-2), env(safe-area-inset-right));
--hoard-mobile-safe-bottom: max(var(--space-3), env(safe-area-inset-bottom)); --ui-mobile-safe-bottom: max(var(--space-3), env(safe-area-inset-bottom));
} }
.v-main { .v-main {
padding-bottom: var(--hoard-mobile-safe-bottom); padding-bottom: var(--ui-mobile-safe-bottom);
} }
.v-btn { .v-btn {
@@ -426,40 +747,52 @@ p {
margin: 4px var(--space-2); margin: 4px var(--space-2);
} }
.hoard-list-row { .ui-list-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-2); gap: var(--space-2);
align-items: start; align-items: start;
padding-block: var(--space-4); padding-block: var(--space-4);
} }
.hoard-toolbar { .ui-toolbar {
flex-wrap: wrap; flex-wrap: wrap;
align-items: stretch; align-items: stretch;
padding: var(--space-3); padding: var(--space-3);
} }
.hoard-empty-state { .ui-empty-state {
padding: var(--space-6) var(--space-4); padding: var(--space-8) var(--space-4);
} }
.v-navigation-drawer { .v-navigation-drawer {
border-right: none !important; border-right: none !important;
border-top: 1px solid var(--color-border) !important; border-top: 1px solid var(--color-border) !important;
padding-bottom: var(--hoard-mobile-safe-bottom); padding-bottom: var(--ui-mobile-safe-bottom);
} }
} }
@media (width <= 600px) { @media (width <= 600px) {
.hoard-toolbar { .ui-toolbar {
gap: var(--space-2); gap: var(--space-2);
} }
.hoard-list-row { .ui-list-row {
padding-inline: var(--space-3); padding-inline: var(--space-3);
} }
.hoard-meta { .ui-meta {
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
} }
} }
/* Vuetify Tooltip lesbarer Kontrast in Light & Dark */
.v-tooltip > .v-overlay__content {
background-color: var(--color-text) !important;
color: var(--color-bg) !important;
font-size: var(--font-size-xs);
font-weight: 500;
padding: 6px 10px;
border-radius: var(--radius-sm, 6px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
opacity: 1 !important;
}
+92 -4
View File
@@ -1,9 +1,15 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import Home from '@/routes/Home.vue' import Home from '@/routes/Home.vue'
import Dashboard from '@/routes/dashboard/Dashboard.vue'
import NotFound from '@/routes/404NotFound.vue' import NotFound from '@/routes/404NotFound.vue'
import Forbidden from '@/routes/Forbidden.vue'
import Login from '@/routes/authentication/Login.vue' import Login from '@/routes/authentication/Login.vue'
import ChangePassword from '@/routes/authentication/ChangePassword.vue'
import AdminUsers from '@/routes/admin/AdminUsers.vue'
import AdminUserDetail from '@/routes/admin/AdminUserDetail.vue'
import Impressum from '@/routes/Impressum.vue' import Impressum from '@/routes/Impressum.vue'
import { ROLE_ADMIN } from '@/services/authSession'
export enum Visibility { export enum Visibility {
Hidden, Hidden,
@@ -23,6 +29,7 @@ export interface LayoutRoute {
disableFooter?: boolean disableFooter?: boolean
visible: Visibility visible: Visibility
visibilityRoute?: string | string[] visibilityRoute?: string | string[]
requiredRoles?: string[]
meta?: RouteRecordRaw meta?: RouteRecordRaw
} }
@@ -38,27 +45,93 @@ export interface LayoutRoute {
*/ */
export const routes: LayoutRoute[] = [ export const routes: LayoutRoute[] = [
{ {
path: '/', path: '/welcome',
name: 'Startseite', name: 'Startseite',
description: 'Self-hosted Datei-Workspace für Hoard', description: 'Self-hosted Datei-Workspace für Hoard',
icon: 'mdi-home', icon: 'mdi-home',
visible: Visibility.Public, visible: Visibility.Unauthenticated,
meta: { meta: {
name: 'Home', name: 'Home',
path: '/', path: '/welcome',
component: Home, component: Home,
}, },
}, },
{
path: '/',
name: 'Dashboard',
description: 'Geschützter Bereich für dein Konto',
icon: 'mdi-view-dashboard-outline',
visible: Visibility.Authenticated,
meta: {
name: 'Dashboard',
path: '/',
component: Dashboard,
meta: {
requiresAuth: true,
},
},
},
{
path: '/admin/users',
name: 'Benutzer',
description: 'Adminbereich für Benutzerverwaltung',
icon: 'mdi-shield-account-outline',
visible: Visibility.Authorized,
requiredRoles: [ROLE_ADMIN],
meta: {
name: 'AdminUsers',
path: '/admin/users',
component: AdminUsers,
meta: {
requiresAuth: true,
requiredRoles: [ROLE_ADMIN],
},
},
},
{
path: '/admin/users/:userId',
name: 'Benutzerdetails',
description: 'Detailansicht eines Benutzerkontos',
icon: 'mdi-account-details-outline',
visible: Visibility.Hidden,
meta: {
name: 'AdminUserDetail',
path: '/admin/users/:userId',
component: AdminUserDetail,
meta: {
requiresAuth: true,
requiredRoles: [ROLE_ADMIN],
},
},
},
{
path: '/password/change',
name: 'Passwort ändern',
description: 'Passwort für dein Konto ändern',
icon: 'mdi-lock-reset',
visible: Visibility.Hidden,
meta: {
path: '/password/change',
name: 'ChangePassword',
component: ChangePassword,
meta: {
requiresAuth: true,
},
},
},
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
description: 'Logge dich ein', description: 'Logge dich ein',
icon: 'mdi-login', icon: 'mdi-login',
visible: Visibility.Hidden, visible: Visibility.Unauthenticated,
meta: { meta: {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
component: Login, component: Login,
meta: {
guestOnly: true,
},
}, },
}, },
{ {
@@ -73,6 +146,21 @@ export const routes: LayoutRoute[] = [
component: Impressum, component: Impressum,
}, },
}, },
{
path: '/forbidden',
name: 'Kein Zugriff',
description: 'Du hast keine Berechtigung für diese Seite',
icon: 'mdi-alert-circle-outline',
visible: Visibility.Hidden,
meta: {
path: '/forbidden',
name: 'Forbidden',
component: Forbidden,
meta: {
requiresAuth: true,
},
},
},
{ {
path: '/notFound', path: '/notFound',
name: 'Nicht gefunden', name: 'Nicht gefunden',
+39 -12
View File
@@ -1,10 +1,4 @@
import 'vuetify/styles' import 'vuetify/styles'
import '@fontsource/roboto/100.css'
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css'
import '@fontsource/roboto/900.css'
import { createVuetify } from 'vuetify' import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components' import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives' import * as directives from 'vuetify/directives'
@@ -14,6 +8,33 @@ import { aliases, mdi } from 'vuetify/iconsets/mdi'
export default createVuetify({ export default createVuetify({
components, components,
directives, directives,
defaults: {
VBtn: {
rounded: 'md',
},
VTextField: {
variant: 'outlined',
density: 'comfortable',
color: 'primary',
},
VTextarea: {
variant: 'outlined',
density: 'comfortable',
color: 'primary',
},
VSelect: {
variant: 'outlined',
density: 'comfortable',
color: 'primary',
},
VAlert: {
variant: 'tonal',
border: 'start',
},
VCard: {
rounded: 'lg',
},
},
theme: { theme: {
defaultTheme: 'light', defaultTheme: 'light',
themes: { themes: {
@@ -21,9 +42,12 @@ export default createVuetify({
dark: false, dark: false,
colors: { colors: {
primary: '#1C652F', primary: '#1C652F',
secondary: '#5F6E62', 'primary-darken-1': '#10421E',
background: '#F6F8F5', secondary: '#5A6A5E',
accent: '#B7E36B',
background: '#F5F8F2',
surface: '#FFFFFF', surface: '#FFFFFF',
'surface-variant': '#F1F4EE',
success: '#2E7D32', success: '#2E7D32',
warning: '#B7791F', warning: '#B7791F',
error: '#C0392B', error: '#C0392B',
@@ -33,10 +57,13 @@ export default createVuetify({
dark: { dark: {
dark: true, dark: true,
colors: { colors: {
primary: '#4EA758', primary: '#5FB968',
secondary: '#A7B0BC', 'primary-darken-1': '#4EA758',
background: '#101215', secondary: '#B6BEC8',
surface: '#171A1F', accent: '#B7E36B',
background: '#0E1115',
surface: '#161A20',
'surface-variant': '#1B2028',
success: '#5FB968', success: '#5FB968',
warning: '#D0A34E', warning: '#D0A34E',
error: '#E07A7A', error: '#E07A7A',
+63
View File
@@ -1,9 +1,72 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { routes } from '@/plugins/routesLayout' import { routes } from '@/plugins/routesLayout'
import { fetchCurrentUser, hasRole } from '@/services/authSession'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: routes.filter((x) => x.meta !== undefined).map((x) => x.meta) as RouteRecordRaw[], routes: routes.filter((x) => x.meta !== undefined).map((x) => x.meta) as RouteRecordRaw[],
}) })
router.beforeEach(async (to) => {
const requiresAuth = to.meta.requiresAuth === true
const guestOnly = to.meta.guestOnly === true
const isPasswordChangeRoute = to.name === 'ChangePassword'
const requiredRoles = Array.isArray(to.meta.requiredRoles)
? to.meta.requiredRoles.filter((role): role is string => typeof role === 'string')
: []
let currentUser = null
try {
currentUser = await fetchCurrentUser()
} catch {
if (requiresAuth || requiredRoles.length > 0 || isPasswordChangeRoute) {
const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
return {
name: 'Login',
query,
replace: true,
}
}
return true
}
const isAuthenticated = currentUser !== null
if (currentUser && currentUser.mustChangePassword && !isPasswordChangeRoute) {
return {
name: 'ChangePassword',
replace: true,
}
}
if (requiresAuth && !isAuthenticated) {
const query = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
return {
name: 'Login',
query,
replace: true,
}
}
if (requiredRoles.length > 0 && !requiredRoles.every((role) => hasRole(currentUser, role))) {
return {
name: 'Forbidden',
replace: true,
}
}
if (guestOnly && isAuthenticated) {
return {
name: 'Dashboard',
replace: true,
}
}
return true
})
export default router export default router
+152 -36
View File
@@ -1,41 +1,115 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import notFoundImage from '@/assets/images/404NotFound.png' import notFoundImage from '@/assets/images/404NotFound.png'
import { fetchCurrentUser } from '@/services/authSession'
const router = useRouter() const router = useRouter()
const isAuthenticated = ref(false)
let autoRedirectTimer: ReturnType<typeof setTimeout> | null = null
const redirectRouteName = computed(() => (isAuthenticated.value ? 'Dashboard' : 'Home'))
const redirectButtonLabel = computed(() =>
isAuthenticated.value ? 'Zum Dashboard' : 'Zur Startseite',
)
const redirectButtonIcon = computed(() =>
isAuthenticated.value ? 'mdi-view-dashboard-outline' : 'mdi-home-outline',
)
const autoRedirectTargetLabel = computed(() => (isAuthenticated.value ? 'Dashboard' : 'Startseite'))
function clearAutoRedirectTimer() {
if (autoRedirectTimer === null) {
return
}
clearTimeout(autoRedirectTimer)
autoRedirectTimer = null
}
function scheduleAutoRedirect() {
clearAutoRedirectTimer()
autoRedirectTimer = setTimeout(() => {
void router.replace({ name: redirectRouteName.value })
}, 4000)
}
async function resolveRedirectTarget() {
try {
const currentUser = await fetchCurrentUser()
isAuthenticated.value = currentUser !== null
} catch {
isAuthenticated.value = false
}
scheduleAutoRedirect()
}
function navigateToPrimaryTarget() {
clearAutoRedirectTimer()
void router.replace({ name: redirectRouteName.value })
}
function navigateBack() { function navigateBack() {
clearAutoRedirectTimer()
if (window.history.length > 1) { if (window.history.length > 1) {
router.back() router.back()
return return
} }
router.push('/') void router.push({ name: redirectRouteName.value })
} }
onMounted(() => {
void resolveRedirectTarget()
})
onBeforeUnmount(() => {
clearAutoRedirectTimer()
})
</script> </script>
<template> <template>
<v-container fluid class="not-found-page hoard-page hoard-page--centered"> <v-container fluid class="not-found-page ui-page ui-page--centered">
<section class="not-found-shell hoard-panel hoard-shell-grid hoard-panel-gradient"> <section class="not-found-shell ui-panel ui-panel-gradient ui-spotlight ui-shell-grid">
<div class="not-found-visual"> <div class="not-found-visual">
<div class="image-frame"> <div class="image-frame">
<img :src="notFoundImage" alt="Illustration für eine nicht gefundene Seite" class="not-found-image" /> <img
:src="notFoundImage"
alt="Illustration für eine nicht gefundene Seite"
class="not-found-image"
/>
</div> </div>
</div> </div>
<div class="not-found-content"> <div class="not-found-content">
<p class="not-found-kicker hoard-kicker hoard-kicker--wide">Fehler 404</p> <p class="not-found-code">404</p>
<h1>Seite nicht gefunden</h1> <p class="ui-kicker ui-kicker--wide">Seite nicht gefunden</p>
<h1>Diese Spur führt ins Leere.</h1>
<p class="not-found-text"> <p class="not-found-text">
Der Link ist ungültig oder die Seite wurde verschoben. Du kannst direkt zur Der Link ist ungültig oder die Seite wurde verschoben. Wir leiten dich gleich zur
Startseite zurück oder die vorherige Ansicht öffnen. {{ autoRedirectTargetLabel }} weiter oder du nutzt direkt einen der Buttons unten.
</p> </p>
<div class="not-found-actions hoard-action-row"> <div class="not-found-actions ui-action-row">
<v-btn color="primary" prepend-icon="mdi-home" to="/">Zur Startseite</v-btn> <v-btn
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="navigateBack">Zurück</v-btn> variant="elevated"
:prepend-icon="redirectButtonIcon"
@click="navigateToPrimaryTarget"
>
{{ redirectButtonLabel }}
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="navigateBack">
Zurück
</v-btn>
</div> </div>
<p class="not-found-hint">
<v-icon icon="mdi-clock-time-four-outline" size="14" />
Auto-Redirect zur {{ autoRedirectTargetLabel }} in wenigen Sekunden.
</p>
</div> </div>
</section> </section>
</v-container> </v-container>
@@ -43,13 +117,15 @@ function navigateBack() {
<style scoped> <style scoped>
.not-found-shell { .not-found-shell {
--hoard-shell-width: 980px; --ui-shell-width: 1040px;
--hoard-gradient-angle: 180deg; --ui-gradient-angle: 130deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-surface) 94%, var(--color-primary-100) 6%); --ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%);
--hoard-gradient-end: color-mix(in srgb, var(--color-surface) 82%, var(--color-surface-alt) 18%); --ui-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 100%; --ui-gradient-end-stop: 65%;
grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr); grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr);
border-radius: var(--radius-xl);
align-items: center;
} }
.not-found-visual { .not-found-visual {
@@ -59,13 +135,20 @@ function navigateBack() {
} }
.image-frame { .image-frame {
width: min(100%, 360px); position: relative;
width: min(100%, 380px);
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
padding: var(--space-4); padding: var(--space-5);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-lg); border-radius: var(--radius-xl);
background-color: color-mix(in srgb, var(--color-surface-alt) 84%, var(--color-surface) 16%); background:
box-shadow: var(--shadow-sm); radial-gradient(
120% 80% at 0% 100%,
color-mix(in srgb, var(--color-primary-100) 50%, transparent),
transparent 70%
),
color-mix(in srgb, var(--color-surface-alt) 86%, var(--color-surface) 14%);
box-shadow: var(--shadow-md);
} }
.not-found-image { .not-found-image {
@@ -80,23 +163,62 @@ function navigateBack() {
justify-content: center; justify-content: center;
} }
h1 { .not-found-code {
margin-bottom: var(--space-3); margin: 0 0 var(--space-3);
font-size: clamp(1.8rem, 2vw + 1rem, 2.4rem); font-family: var(--font-family-mono);
font-size: 88px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.05em;
line-height: 1;
background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-500) 60%, var(--color-accent-lime) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
h1 {
margin: 0 0 var(--space-3);
font-size: clamp(1.8rem, 1.4rem + 1vw, 2.4rem);
font-weight: 700;
letter-spacing: -0.02em;
} }
.not-found-text { .not-found-text {
margin-bottom: var(--space-6); margin: 0 0 var(--space-5);
max-width: 44ch; max-width: 48ch;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 15px; font-size: var(--font-size-md);
line-height: 1.6;
} }
.not-found-actions { .not-found-actions {
align-items: center; align-items: center;
} }
.not-found-hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-5) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.not-found-hint .v-icon {
color: var(--color-primary-700);
}
@media (prefers-reduced-motion: no-preference) {
.not-found-visual,
.not-found-content {
animation: ui-soft-enter 320ms both;
}
.not-found-content {
animation-delay: 100ms;
}
}
@media (width <= 960px) { @media (width <= 960px) {
.not-found-shell { .not-found-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -122,23 +244,17 @@ h1 {
} }
@media (width <= 600px) { @media (width <= 600px) {
.not-found-shell { .not-found-code {
--hoard-shell-padding-block-mobile-xs: var(--space-4); font-size: 64px;
--hoard-shell-padding-inline-mobile-xs: var(--space-3);
}
h1 {
font-size: clamp(1.5rem, 7vw, 1.9rem);
} }
.not-found-text { .not-found-text {
margin-bottom: var(--space-4);
font-size: var(--font-size-md); font-size: var(--font-size-md);
} }
.image-frame { .image-frame {
width: min(100%, 260px); width: min(100%, 260px);
padding: var(--space-3); padding: var(--space-4);
} }
.not-found-actions { .not-found-actions {
+173
View File
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchCurrentUser } from '@/services/authSession'
const router = useRouter()
const isLoading = ref(true)
const isAuthenticated = ref(false)
const primaryActionLabel = computed(() => (isAuthenticated.value ? 'Zum Dashboard' : 'Zum Login'))
const primaryActionIcon = computed(() =>
isAuthenticated.value ? 'mdi-view-dashboard-outline' : 'mdi-login',
)
async function resolveAuthState() {
try {
const user = await fetchCurrentUser({ force: true })
isAuthenticated.value = user !== null
} catch {
isAuthenticated.value = false
} finally {
isLoading.value = false
}
}
async function navigatePrimaryAction() {
if (isAuthenticated.value) {
await router.replace({ name: 'Dashboard' })
return
}
await router.replace({ name: 'Login' })
}
async function navigateBack() {
if (window.history.length > 1) {
router.back()
return
}
await router.replace({ name: isAuthenticated.value ? 'Dashboard' : 'Home' })
}
onMounted(() => {
void resolveAuthState()
})
</script>
<template>
<v-container fluid class="forbidden-page ui-page ui-page--centered">
<section class="forbidden-shell ui-panel ui-panel-gradient ui-spotlight">
<div class="forbidden-icon">
<span class="forbidden-icon__halo" aria-hidden="true" />
<v-icon icon="mdi-shield-alert-outline" size="40" />
</div>
<header class="forbidden-head">
<p class="ui-kicker ui-kicker--wide">Fehlende Berechtigung</p>
<h1>Kein Zugriff.</h1>
<p>
Dein Konto hat aktuell keine ausreichende Rolle, um diese Seite zu sehen. Falls das ein Fehler ist,
wende dich an einen Admin oder wechsle zurück in deinen freigegebenen Bereich.
</p>
</header>
<div class="forbidden-actions ui-action-row">
<v-btn
variant="elevated"
:prepend-icon="primaryActionIcon"
:loading="isLoading"
:disabled="isLoading"
@click="navigatePrimaryAction"
>
{{ primaryActionLabel }}
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="navigateBack">
Zurück
</v-btn>
</div>
</section>
</v-container>
</template>
<style scoped>
.forbidden-page {
--ui-centered-offset: 200px;
}
.forbidden-shell {
--ui-gradient-angle: 130deg;
--ui-gradient-start: color-mix(in srgb, var(--color-warning) 14%, var(--color-surface) 86%);
--ui-gradient-end: var(--color-surface);
--ui-gradient-end-stop: 65%;
display: flex;
flex-direction: column;
gap: var(--space-5);
align-items: center;
text-align: center;
width: min(640px, 100%);
padding: var(--space-10) var(--space-7);
border-radius: var(--radius-xl);
}
.forbidden-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background:
linear-gradient(135deg, var(--color-warning), color-mix(in srgb, var(--color-warning) 60%, var(--color-danger) 40%));
color: #fff;
box-shadow:
var(--shadow-md),
inset 0 0 0 2px color-mix(in srgb, var(--color-accent-lime) 18%, transparent);
}
.forbidden-icon__halo {
position: absolute;
inset: -10px;
border-radius: var(--radius-full);
background:
radial-gradient(
closest-side,
color-mix(in srgb, var(--color-warning) 38%, transparent),
transparent 70%
);
filter: blur(12px);
opacity: 0.6;
}
.forbidden-head h1 {
margin: 0 0 var(--space-2);
font-size: clamp(1.8rem, 1.4rem + 1vw, 2.4rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.forbidden-head p {
margin: 0 auto;
max-width: 50ch;
color: var(--color-text-secondary);
line-height: 1.6;
}
.forbidden-actions {
justify-content: center;
}
@media (prefers-reduced-motion: no-preference) {
.forbidden-shell {
animation: ui-soft-enter 280ms both;
}
}
@media (width <= 600px) {
.forbidden-shell {
padding: var(--space-7) var(--space-5);
}
.forbidden-actions {
width: 100%;
}
:deep(.forbidden-actions .v-btn) {
width: 100%;
min-height: 44px;
}
}
</style>
+494 -179
View File
@@ -1,19 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import iconImage from '@/assets/images/icon.svg'
const valueProps = [ const valueProps = [
{ {
icon: 'mdi-folder-multiple-outline', icon: 'mdi-folder-multiple-outline',
title: 'Dateien zuerst', title: 'Dateien zuerst',
text: 'Ordner, Dateiliste und Vorschau sind der Kern. Kein überladenes Dashboard-Gefühl.', text: 'Ordner, Dateiliste und Vorschau bilden den Kern kein überladenes Dashboard, keine Ablenkung.',
}, },
{ {
icon: 'mdi-file-document-edit-outline', icon: 'mdi-file-document-edit-outline',
title: 'Markdown direkt im Browser', title: 'Markdown direkt im Browser',
text: 'Dokumente bearbeiten, lesen und strukturieren ohne Tool-Wechsel.', text: 'Notizen, Doku und Konzepte ohne Tool-Wechsel öffnen, lesen und sauber strukturieren.',
}, },
{ {
icon: 'mdi-server-outline', icon: 'mdi-server-outline',
title: 'Self-hosted Kontrolle', title: 'Self-hosted Kontrolle',
text: 'Dateimetadaten in PostgreSQL, Dateien in MinIO, alles auf deinem Server.', text: 'Metadaten in PostgreSQL, Dateien in MinIO. Volle Datenhoheit auf deinem eigenen Server.',
}, },
] ]
@@ -26,17 +28,17 @@ const coreFeatures = [
{ {
icon: 'mdi-image-outline', icon: 'mdi-image-outline',
title: 'PDF- und Bildvorschau', title: 'PDF- und Bildvorschau',
text: 'Dateien öffnen und direkt einsehen, ohne externe Viewer oder Downloads.', text: 'Dateien direkt einsehen, ohne externe Viewer oder ständige Downloads.',
}, },
{ {
icon: 'mdi-account-lock-outline', icon: 'mdi-account-lock-outline',
title: 'Klare Benutzerlogik', title: 'Klare Benutzerlogik',
text: 'Keine offene Registrierung. Accounts werden bewusst und kontrolliert verwaltet.', text: 'Keine offene Registrierung. Konten werden bewusst und kontrolliert verwaltet.',
}, },
{ {
icon: 'mdi-lightning-bolt-outline', icon: 'mdi-lightning-bolt-outline',
title: 'Schlankes MVP-Setup', title: 'Schlankes MVP-Setup',
text: 'Fokus auf das Wesentliche: stabil, wartbar und realistisch für Solo-Entwicklung.', text: 'Fokus auf das Wesentliche stabil, wartbar und realistisch für Solo-Entwicklung.',
}, },
] ]
@@ -44,12 +46,12 @@ const workflowSteps = [
{ {
number: '01', number: '01',
title: 'Anmelden', title: 'Anmelden',
text: 'Melde dich mit einem vorhandenen Konto an und starte direkt in deiner Dateiablage.', text: 'Mit deinem bestehenden Konto einsteigen und direkt in deiner Dateiablage starten.',
}, },
{ {
number: '02', number: '02',
title: 'Dateien strukturieren', title: 'Dateien strukturieren',
text: 'Lege Ordner an, lade Dateien hoch und halte deine Arbeitsbereiche aufgeräumt.', text: 'Ordner anlegen, Inhalte hochladen und Arbeitsbereiche aufgeräumt halten.',
}, },
{ {
number: '03', number: '03',
@@ -58,96 +60,162 @@ const workflowSteps = [
}, },
] ]
const techStack = ['Vue 3', 'ASP.NET Core', 'PostgreSQL', 'MinIO', 'md-editor-v3', 'Cookie Auth'] const techStack = [
{ label: 'Vue 3', icon: 'mdi-vuejs' },
{ label: 'ASP.NET Core', icon: 'mdi-dot-net' },
{ label: 'PostgreSQL', icon: 'mdi-database-outline' },
{ label: 'MinIO', icon: 'mdi-cloud-outline' },
{ label: 'md-editor-v3', icon: 'mdi-language-markdown-outline' },
{ label: 'Cookie-Auth', icon: 'mdi-cookie-outline' },
]
</script> </script>
<template> <template>
<v-container fluid class="landing-page hoard-page"> <v-container fluid class="landing-page ui-page">
<section class="hero hoard-panel hoard-panel-gradient"> <section class="hero ui-panel ui-panel-gradient ui-spotlight">
<div class="hero-copy"> <div class="hero-copy">
<p class="hero-kicker hoard-kicker">Self-hosted Datei-Workspace</p> <p class="hero-kicker ui-kicker ui-kicker--wide">Self-hosted Datei-Workspace</p>
<h1>Hoard ist deine ruhige Startseite für Dateien, Ordner und Markdown.</h1> <h1>
Eine ruhige Heimat für deine
<span class="hero-accent">Dateien, Ordner und Notizen.</span>
</h1>
<p class="hero-lead"> <p class="hero-lead">
Eine einfache, Google-Drive-inspirierte Web-App für Teams, die volle Kontrolle über Hoard ist eine Google-Drive-inspirierte Web-App für Teams, die volle Kontrolle über
Daten, Struktur und Workflow behalten wollen. Daten, Struktur und Workflow behalten wollen ohne Cloud-Lock-in, ohne SaaS-Abo.
</p> </p>
<div class="hero-actions hoard-action-row"> <div class="hero-actions ui-action-row">
<v-btn color="primary" size="large" prepend-icon="mdi-login" to="/login"> <v-btn variant="elevated" size="large" prepend-icon="mdi-login" to="/login">
Zum Login Anmelden
</v-btn> </v-btn>
<v-btn variant="outlined" size="large" prepend-icon="mdi-file-document-outline" to="/impressum"> <v-btn variant="outlined" size="large" prepend-icon="mdi-arrow-right" to="/impressum">
Mehr erfahren Mehr erfahren
</v-btn> </v-btn>
</div> </div>
<div class="hero-tags"> <div class="hero-tags">
<span class="hero-tag">Light-first UX</span> <span class="ui-chip ui-chip--brand">
<span class="hero-tag">Mehrbenutzerfähig</span> <v-icon icon="mdi-shield-check-outline" size="14" /> Datenhoheit
<span class="hero-tag">Ohne SaaS-Abhängigkeit</span> </span>
<span class="ui-chip">
<v-icon icon="mdi-account-multiple-outline" size="14" /> Mehrbenutzerfähig
</span>
<span class="ui-chip">
<v-icon icon="mdi-weather-night" size="14" /> Light- und Dark-Mode
</span>
</div> </div>
</div> </div>
<div class="hero-preview hoard-panel"> <aside class="hero-preview" aria-hidden="true">
<header class="preview-head"> <div class="preview-window">
<p class="preview-title">Beispielansicht</p> <header class="preview-window__head">
<span class="preview-pill">Workspace</span> <span class="preview-window__dots">
</header> <span /><span /><span />
<div class="preview-list"> </span>
<article class="preview-row"> <span class="preview-window__path">
<v-icon icon="mdi-folder-outline" size="18" /> hoard / dokumentation
<div> </span>
<p class="row-title">Dokumentation</p> </header>
<p class="row-meta">Ordner · vor 3 Tagen aktualisiert</p>
<div class="preview-window__body">
<header class="preview-toolbar">
<span class="preview-toolbar__title">Dokumentation</span>
<span class="ui-chip ui-chip--brand">
<v-icon icon="mdi-folder-outline" size="14" /> 3 Ordner · 12 Dateien
</span>
</header>
<div class="preview-list">
<article class="preview-row">
<span class="preview-row__icon">
<v-icon icon="mdi-folder-outline" size="18" />
</span>
<div class="preview-row__body">
<p class="preview-row__title">Konzepte</p>
<p class="preview-row__meta">Ordner · vor 2 Tagen</p>
</div>
<span class="ui-status ui-status--muted">Geteilt</span>
</article>
<article class="preview-row preview-row--active">
<span class="preview-row__icon">
<v-icon icon="mdi-language-markdown-outline" size="18" />
</span>
<div class="preview-row__body">
<p class="preview-row__title">roadmap.md</p>
<p class="preview-row__meta">Markdown · 18 KB</p>
</div>
<span class="ui-status ui-status--success">Editor</span>
</article>
<article class="preview-row">
<span class="preview-row__icon">
<v-icon icon="mdi-file-pdf-box" size="18" />
</span>
<div class="preview-row__body">
<p class="preview-row__title">api-reference.pdf</p>
<p class="preview-row__meta">PDF · 1,2 MB</p>
</div>
<span class="ui-status ui-status--info">Vorschau</span>
</article>
<article class="preview-row">
<span class="preview-row__icon">
<v-icon icon="mdi-image-outline" size="18" />
</span>
<div class="preview-row__body">
<p class="preview-row__title">screen-2026-04.png</p>
<p class="preview-row__meta">Bild · 480 KB</p>
</div>
<span class="ui-status ui-status--muted">Bereit</span>
</article>
</div> </div>
</article> </div>
<article class="preview-row">
<v-icon icon="mdi-file-document-outline" size="18" />
<div>
<p class="row-title">roadmap.md</p>
<p class="row-meta">Markdown · 18 KB</p>
</div>
</article>
<article class="preview-row">
<v-icon icon="mdi-file-pdf-box" size="18" />
<div>
<p class="row-title">api-reference.pdf</p>
<p class="row-meta">PDF · 1.2 MB</p>
</div>
</article>
</div> </div>
</div>
<div class="preview-glow" aria-hidden="true">
<img :src="iconImage" alt="" class="preview-glow__logo" />
</div>
</aside>
</section> </section>
<section class="value-grid"> <section class="value-grid">
<article v-for="item in valueProps" :key="item.title" class="value-card hoard-panel"> <article v-for="item in valueProps" :key="item.title" class="value-card ui-panel">
<v-icon :icon="item.icon" size="22" /> <span class="ui-icon-tile ui-icon-tile--lg">
<v-icon :icon="item.icon" size="22" />
</span>
<h2>{{ item.title }}</h2> <h2>{{ item.title }}</h2>
<p>{{ item.text }}</p> <p>{{ item.text }}</p>
</article> </article>
</section> </section>
<section class="feature-section hoard-panel"> <section class="feature-section ui-panel">
<header class="section-head"> <header class="ui-section-head">
<p class="section-kicker hoard-kicker">Für den Produktivalltag</p> <p class="ui-kicker">Für den Produktivalltag</p>
<h2>Weniger Tool-Chaos, mehr Fokus auf Inhalte</h2> <h2>Weniger Tool-Chaos, mehr Fokus auf Inhalte.</h2>
<p>Hoard bündelt Datei- und Markdown-Workflows in einer ruhigen Oberfläche, die nicht ablenkt.</p>
</header> </header>
<div class="feature-grid"> <div class="feature-grid">
<article v-for="feature in coreFeatures" :key="feature.title" class="feature-card"> <article v-for="feature in coreFeatures" :key="feature.title" class="feature-card">
<v-icon :icon="feature.icon" size="20" /> <span class="ui-icon-tile">
<h3>{{ feature.title }}</h3> <v-icon :icon="feature.icon" size="20" />
<p>{{ feature.text }}</p> </span>
<div>
<h3>{{ feature.title }}</h3>
<p>{{ feature.text }}</p>
</div>
</article> </article>
</div> </div>
</section> </section>
<section class="workflow-section"> <section class="workflow-section">
<header class="section-head"> <header class="ui-section-head">
<p class="section-kicker hoard-kicker">So funktioniert Hoard</p> <p class="ui-kicker">So funktioniert Hoard</p>
<h2>In drei klaren Schritten produktiv starten</h2> <h2>In drei klaren Schritten produktiv starten.</h2>
</header> </header>
<div class="workflow-grid"> <div class="workflow-grid">
<article v-for="step in workflowSteps" :key="step.number" class="workflow-card hoard-panel"> <article v-for="step in workflowSteps" :key="step.number" class="workflow-card ui-panel">
<p class="workflow-number">{{ step.number }}</p> <p class="workflow-number">{{ step.number }}</p>
<h3>{{ step.title }}</h3> <h3>{{ step.title }}</h3>
<p>{{ step.text }}</p> <p>{{ step.text }}</p>
@@ -155,17 +223,20 @@ const techStack = ['Vue 3', 'ASP.NET Core', 'PostgreSQL', 'MinIO', 'md-editor-v3
</div> </div>
</section> </section>
<section class="stack-section hoard-panel"> <section class="stack-section ui-panel ui-panel-gradient">
<div class="stack-copy"> <div class="stack-copy">
<p class="section-kicker hoard-kicker">Technische Basis</p> <p class="ui-kicker">Technische Basis</p>
<h2>Schlank gebaut für ein realistisches MVP</h2> <h2>Schlank gebaut für ein realistisches MVP.</h2>
<p class="stack-text"> <p>
Hoard kombiniert einen modernen Frontend-Stack mit einem pragmatischen Backend-Setup, Hoard kombiniert einen modernen Frontend-Stack mit einem pragmatischen Backend-Setup,
damit Weiterentwicklung und Betrieb auch solo gut machbar bleiben. damit Weiterentwicklung und Betrieb auch solo machbar bleiben.
</p> </p>
</div> </div>
<div class="stack-list"> <div class="stack-list">
<span v-for="item in techStack" :key="item" class="stack-pill">{{ item }}</span> <span v-for="item in techStack" :key="item.label" class="stack-pill">
<v-icon :icon="item.icon" size="16" />
{{ item.label }}
</span>
</div> </div>
</section> </section>
</v-container> </v-container>
@@ -173,45 +244,59 @@ const techStack = ['Vue 3', 'ASP.NET Core', 'PostgreSQL', 'MinIO', 'md-editor-v3
<style scoped> <style scoped>
.landing-page { .landing-page {
--hoard-page-width: 1180px; --ui-page-width: 1200px;
} }
/* ---------- Hero ---------- */
.hero { .hero {
--hoard-gradient-angle: 120deg; --ui-gradient-angle: 130deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 34%, var(--color-surface) 66%); --ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 70%, var(--color-surface) 30%);
--hoard-gradient-end: var(--color-surface); --ui-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 52%; --ui-gradient-end-stop: 60%;
position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: var(--space-6); gap: var(--space-10);
padding: var(--space-8); padding: var(--space-12);
border-radius: var(--radius-xl);
overflow: hidden;
} }
.workflow-number, .hero-copy {
.preview-title, display: flex;
.row-title, flex-direction: column;
.row-meta, justify-content: center;
.stack-text { position: relative;
margin: 0; z-index: 1;
} }
h1 { h1 {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
max-width: 20ch; max-width: 18ch;
font-size: clamp(2rem, 2.5vw + 1rem, 3rem); font-size: clamp(2.4rem, 1.6rem + 1.6vw, 3.2rem);
line-height: 1.08; font-weight: 700;
line-height: 1.05;
letter-spacing: -0.025em;
}
.hero-accent {
background: linear-gradient(120deg, var(--color-primary-700) 30%, var(--color-primary-500) 70%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
.hero-lead { .hero-lead {
margin-bottom: var(--space-5); margin-bottom: var(--space-6);
max-width: 50ch; max-width: 56ch;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 15px; font-size: var(--font-size-lg);
line-height: 1.6;
} }
.hero-actions { .hero-actions {
margin-bottom: var(--space-4); margin-bottom: var(--space-5);
} }
.hero-tags { .hero-tags {
@@ -220,48 +305,89 @@ h1 {
gap: var(--space-2); gap: var(--space-2);
} }
.hero-tag { /* ---------- Hero preview ---------- */
display: inline-flex;
align-items: center;
padding: 5px var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: 999px;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 500;
}
.hero-preview { .hero-preview {
position: relative;
align-self: center; align-self: center;
padding: var(--space-5); z-index: 1;
width: 100%;
background-color: color-mix(in srgb, var(--color-surface-alt) 86%, var(--color-surface) 14%);
} }
.preview-head { .preview-window {
position: relative;
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-lg);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 96%, var(--color-surface-alt) 4%),
var(--color-surface)
);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.preview-window__head {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-subtle);
background-color: var(--color-surface-alt);
}
.preview-window__dots {
display: inline-flex;
gap: 6px;
}
.preview-window__dots span {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
background-color: color-mix(in srgb, var(--color-border-strong) 80%, var(--color-text-muted) 20%);
}
.preview-window__dots span:first-child {
background-color: color-mix(in srgb, var(--color-danger) 60%, var(--color-border-strong) 40%);
}
.preview-window__dots span:nth-child(2) {
background-color: color-mix(in srgb, var(--color-warning) 60%, var(--color-border-strong) 40%);
}
.preview-window__dots span:last-child {
background-color: color-mix(in srgb, var(--color-success) 60%, var(--color-border-strong) 40%);
}
.preview-window__path {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
letter-spacing: 0.02em;
}
.preview-window__body {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.preview-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-4); flex-wrap: wrap;
} }
.preview-title { .preview-toolbar__title {
color: var(--color-text); color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 600; font-weight: 600;
} }
.preview-pill {
display: inline-flex;
align-items: center;
padding: 4px var(--space-2);
border-radius: 999px;
color: var(--color-primary-700);
font-size: var(--font-size-xs);
font-weight: 600;
background-color: var(--color-primary-100);
}
.preview-list { .preview-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -270,56 +396,127 @@ h1 {
.preview-row { .preview-row {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr auto;
gap: var(--space-3); gap: var(--space-3);
align-items: center; align-items: center;
padding: var(--space-3); padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-surface) 25%); border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background-color: var(--color-surface); background-color: var(--color-surface);
transition:
border-color var(--transition-fast),
background-color var(--transition-fast);
} }
.row-title { .preview-row:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 50%, var(--color-border) 50%);
background-color: color-mix(in srgb, var(--color-primary-100) 28%, var(--color-surface) 72%);
}
.preview-row--active {
border-color: color-mix(in srgb, var(--color-primary-500) 60%, var(--color-border) 40%);
background-color: color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%);
}
.preview-row__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
background-color: color-mix(in srgb, var(--color-surface-alt) 70%, var(--color-surface) 30%);
color: var(--color-text-secondary);
}
.preview-row--active .preview-row__icon {
background-color: color-mix(in srgb, var(--color-primary-100) 80%, var(--color-surface) 20%);
color: var(--color-primary-700);
}
.preview-row__title,
.preview-row__meta {
margin: 0;
}
.preview-row__title {
color: var(--color-text); color: var(--color-text);
font-size: var(--font-size-md); font-size: var(--font-size-md);
font-weight: 500; font-weight: 500;
} }
.row-meta { .preview-row__meta {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
} }
.preview-glow {
position: absolute;
inset: -10% -10% auto auto;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
filter: blur(0.6px);
opacity: 0.16;
}
.preview-glow__logo {
width: 70%;
height: 70%;
object-fit: contain;
}
/* ---------- Value grid ---------- */
.value-grid { .value-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4); gap: var(--space-5);
} }
.value-card { .value-card {
padding: var(--space-5); padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
} }
.value-card h2, .value-card,
.section-head h2 { .workflow-card,
margin: var(--space-3) 0 var(--space-2); .feature-card {
font-size: clamp(1.25rem, 1.2vw + 0.8rem, 1.8rem); transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
}
.value-card:hover,
.workflow-card:hover,
.feature-card:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 50%, var(--color-border) 50%);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.value-card h2 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: 600;
letter-spacing: -0.01em;
} }
.value-card p, .value-card p,
.feature-card p, .feature-card p,
.workflow-card p, .workflow-card p {
.stack-text { margin: 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.feature-section, /* ---------- Feature section ---------- */
.stack-section { .feature-section {
padding: var(--space-6); padding: var(--space-8);
}
.section-head {
margin-bottom: var(--space-5);
} }
.feature-grid { .feature-grid {
@@ -329,40 +526,88 @@ h1 {
} }
.feature-card { .feature-card {
padding: var(--space-4); display: grid;
border: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-surface) 25%); grid-template-columns: auto 1fr;
gap: var(--space-4);
align-items: flex-start;
padding: var(--space-5);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface) 80%, var(--color-surface-alt) 20%); background-color: var(--color-surface);
} }
.feature-card h3, .feature-card h3,
.workflow-card h3, .workflow-card h3 {
.stack-copy h2 { margin: 0 0 var(--space-1);
margin: var(--space-2) 0; font-size: var(--font-size-lg);
font-weight: 600;
letter-spacing: -0.01em;
} }
/* ---------- Workflow ---------- */
.workflow-grid { .workflow-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4); gap: var(--space-5);
} }
.workflow-card { .workflow-card {
padding: var(--space-5); position: relative;
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.workflow-card::before {
content: '';
position: absolute;
left: var(--space-6);
top: var(--space-6);
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--color-primary-100) 70%, var(--color-surface) 30%);
filter: blur(8px);
opacity: 0.7;
z-index: 0;
} }
.workflow-number { .workflow-number {
position: relative;
margin: 0;
color: var(--color-primary-700); color: var(--color-primary-700);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 700; font-weight: 700;
letter-spacing: 0.05em; letter-spacing: 0.06em;
z-index: 1;
} }
/* ---------- Stack ---------- */
.stack-section { .stack-section {
--ui-gradient-angle: 110deg;
--ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 50%, var(--color-surface) 50%);
--ui-gradient-end: var(--color-surface);
--ui-gradient-end-stop: 70%;
display: grid; display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-6); gap: var(--space-8);
align-items: center; align-items: center;
padding: var(--space-8);
}
.stack-copy h2 {
margin: 0 0 var(--space-3);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.stack-copy p {
margin: 0;
color: var(--color-text-secondary);
max-width: 56ch;
} }
.stack-list { .stack-list {
@@ -372,43 +617,113 @@ h1 {
} }
.stack-pill { .stack-pill {
padding: 6px var(--space-3); display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 8px var(--space-4);
border: 1px solid var(--color-border-strong); border: 1px solid var(--color-border-strong);
border-radius: var(--radius-md); border-radius: var(--radius-full);
background-color: var(--color-surface);
color: var(--color-text); color: var(--color-text);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 500; font-weight: 500;
background-color: color-mix(in srgb, var(--color-surface-alt) 75%, var(--color-surface) 25%); box-shadow: var(--shadow-xs);
transition: transform var(--transition-fast), border-color var(--transition-fast);
} }
@media (width <= 1100px) { .stack-pill:hover {
.hero, transform: translateY(-1px);
border-color: color-mix(in srgb, var(--color-primary-300) 60%, var(--color-border-strong) 40%);
}
.stack-pill .v-icon {
color: var(--color-primary-700);
}
/* ---------- Animations ---------- */
@media (prefers-reduced-motion: no-preference) {
.hero-copy,
.hero-preview,
.value-card,
.feature-section,
.workflow-section,
.stack-section { .stack-section {
animation: ui-soft-enter 320ms both;
}
.hero-preview {
animation-delay: 80ms;
}
.value-card:nth-child(2) {
animation-delay: 80ms;
}
.value-card:nth-child(3),
.feature-section {
animation-delay: 140ms;
}
.workflow-section {
animation-delay: 180ms;
}
.stack-section {
animation-delay: 220ms;
}
.preview-row {
animation: ui-soft-enter 240ms both;
}
.preview-row:nth-child(2) {
animation-delay: 60ms;
}
.preview-row:nth-child(3) {
animation-delay: 120ms;
}
.preview-row:nth-child(4) {
animation-delay: 180ms;
}
}
/* ---------- Responsive ---------- */
@media (width <= 1100px) {
.hero {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-8);
padding: var(--space-8);
} }
.hero-preview { .hero-preview {
max-width: 720px; max-width: 720px;
} }
.stack-section {
grid-template-columns: 1fr;
gap: var(--space-6);
}
} }
@media (width <= 960px) { @media (width <= 960px) {
.hero, .hero,
.feature-section, .feature-section,
.stack-section { .stack-section {
padding: var(--space-5); padding: var(--space-6);
}
.hero-actions {
gap: var(--space-2);
} }
.hero-preview { .hero-preview {
padding: var(--space-4); max-width: none;
}
.preview-window__body {
padding: var(--space-3);
} }
.preview-row { .preview-row {
padding: var(--space-4); padding: var(--space-3) var(--space-4);
} }
.value-grid, .value-grid,
@@ -420,22 +735,25 @@ h1 {
h1 { h1 {
max-width: none; max-width: none;
} }
.feature-card {
padding: var(--space-4);
}
} }
@media (width <= 600px) { @media (width <= 600px) {
.hero, .hero,
.feature-section, .feature-section,
.stack-section { .stack-section,
padding: var(--space-4); .feature-card,
} .value-card,
.workflow-card {
h1 { padding: var(--space-5);
font-size: clamp(1.6rem, 8vw, 2rem);
} }
.hero-lead { .hero-lead {
margin-bottom: var(--space-4);
font-size: var(--font-size-md); font-size: var(--font-size-md);
margin-bottom: var(--space-5);
} }
.hero-actions { .hero-actions {
@@ -451,20 +769,17 @@ h1 {
gap: 6px; gap: 6px;
} }
.hero-tag {
padding: 4px var(--space-2);
font-size: var(--font-size-xs);
}
.value-card,
.workflow-card,
.feature-card {
padding: var(--space-4);
}
.stack-pill { .stack-pill {
width: 100%; width: 100%;
text-align: center; justify-content: center;
}
.preview-row {
grid-template-columns: auto 1fr;
}
.preview-row .ui-status {
display: none;
} }
} }
</style> </style>
+154 -113
View File
@@ -13,24 +13,28 @@ const registerDetails = [
const contactDetails = [ const contactDetails = [
{ label: 'Telefon', value: '+49 30 1234567-0', href: 'tel:+493012345670' }, { label: 'Telefon', value: '+49 30 1234567-0', href: 'tel:+493012345670' },
{ label: 'E-Mail', value: 'kontakt@hoard-demo.de', href: 'mailto:kontakt@hoard-demo.de' }, { label: 'E-Mail', value: 'kontakt@ui-demo.de', href: 'mailto:kontakt@ui-demo.de' },
{ label: 'Support', value: 'support@hoard-demo.de', href: 'mailto:support@hoard-demo.de' }, { label: 'Support', value: 'support@ui-demo.de', href: 'mailto:support@ui-demo.de' },
] ]
const legalNotes = [ const legalNotes = [
{ {
icon: 'mdi-account-voice',
title: 'Verantwortlich für den Inhalt', title: 'Verantwortlich für den Inhalt',
text: 'Julia Beispiel, Musterstraße 42, 12345 Musterstadt, Deutschland (Testdaten).', text: 'Julia Beispiel, Musterstraße 42, 12345 Musterstadt, Deutschland (Testdaten).',
}, },
{ {
icon: 'mdi-scale-balance',
title: 'EU-Streitbeilegung', title: 'EU-Streitbeilegung',
text: 'Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung bereit: https://ec.europa.eu/consumers/odr/. Wir sind nicht verpflichtet und nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.', text: 'Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung bereit. Wir sind nicht verpflichtet und nicht bereit, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.',
}, },
{ {
icon: 'mdi-shield-check-outline',
title: 'Haftung für Inhalte', title: 'Haftung für Inhalte',
text: 'Als Diensteanbieter sind wir für eigene Inhalte nach den allgemeinen Gesetzen verantwortlich. Für fremde Inhalte, auf die wir verweisen, übernehmen wir keine Gewähr. Dieses Impressum enthält ausschließlich Demo-Angaben.', text: 'Als Diensteanbieter sind wir für eigene Inhalte nach den allgemeinen Gesetzen verantwortlich. Für fremde Inhalte, auf die wir verweisen, übernehmen wir keine Gewähr. Dieses Impressum enthält ausschließlich Demo-Angaben.',
}, },
{ {
icon: 'mdi-link-variant',
title: 'Haftung für Links', title: 'Haftung für Links',
text: 'Unsere Seiten enthalten Links zu externen Webseiten Dritter. Für deren Inhalte ist stets der jeweilige Anbieter verantwortlich. Bei Bekanntwerden von Rechtsverletzungen werden derartige Links umgehend entfernt.', text: 'Unsere Seiten enthalten Links zu externen Webseiten Dritter. Für deren Inhalte ist stets der jeweilige Anbieter verantwortlich. Bei Bekanntwerden von Rechtsverletzungen werden derartige Links umgehend entfernt.',
}, },
@@ -38,31 +42,40 @@ const legalNotes = [
</script> </script>
<template> <template>
<v-container fluid class="impressum-page hoard-page"> <v-container fluid class="impressum-page ui-page">
<section class="impressum-hero hoard-panel hoard-panel-gradient"> <section class="impressum-hero ui-panel ui-panel-gradient ui-spotlight">
<div class="hero-copy"> <div class="impressum-hero__copy">
<p class="hero-kicker hoard-kicker">Rechtliche Angaben</p> <p class="ui-kicker ui-kicker--wide">Rechtliche Angaben</p>
<h1>Impressum</h1> <h1>Impressum</h1>
<p class="hero-lead"> <p class="impressum-hero__lead">
Diese Seite ist im Hoard-Design aufgebaut und mit Testdaten gefüllt. Ersetze die Angaben Diese Seite ist im Hoard-Design aufgebaut und mit Testdaten gefüllt. Vor produktivem
vor einem produktiven Einsatz mit deinen echten Unternehmensdaten. Einsatz bitte alle Angaben durch echte Unternehmensdaten ersetzen.
</p> </p>
<div class="hero-meta"> <div class="impressum-hero__meta">
<span class="meta-pill">Testdaten</span> <span class="ui-chip ui-chip--brand">
<span class="meta-text">Stand: 17. April 2026</span> <v-icon icon="mdi-information-outline" size="14" /> Testdaten
</span>
<span class="ui-chip">
<v-icon icon="mdi-calendar-month-outline" size="14" /> Stand: 26. April 2026
</span>
</div> </div>
</div> </div>
<div class="hero-actions hoard-action-row"> <div class="impressum-hero__actions ui-action-row">
<v-btn color="primary" prepend-icon="mdi-home" to="/">Zur Startseite</v-btn> <v-btn variant="elevated" prepend-icon="mdi-home-outline" to="/welcome">
Zur Startseite
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-login" to="/login">Zum Login</v-btn> <v-btn variant="outlined" prepend-icon="mdi-login" to="/login">Zum Login</v-btn>
</div> </div>
</section> </section>
<section class="details-grid"> <section class="details-grid">
<article class="detail-card hoard-panel"> <article class="detail-card ui-panel">
<h2>Anbieterangaben</h2> <header class="detail-card__head">
<span class="ui-icon-tile"><v-icon icon="mdi-domain" size="20" /></span>
<h2>Anbieterangaben</h2>
</header>
<dl class="detail-list"> <dl class="detail-list">
<div v-for="entry in companyDetails" :key="entry.label" class="detail-item"> <div v-for="entry in companyDetails" :key="entry.label" class="detail-item">
<dt>{{ entry.label }}</dt> <dt>{{ entry.label }}</dt>
@@ -71,8 +84,11 @@ const legalNotes = [
</dl> </dl>
</article> </article>
<article class="detail-card hoard-panel"> <article class="detail-card ui-panel">
<h2>Kontakt</h2> <header class="detail-card__head">
<span class="ui-icon-tile"><v-icon icon="mdi-email-outline" size="20" /></span>
<h2>Kontakt</h2>
</header>
<dl class="detail-list"> <dl class="detail-list">
<div v-for="entry in contactDetails" :key="entry.label" class="detail-item"> <div v-for="entry in contactDetails" :key="entry.label" class="detail-item">
<dt>{{ entry.label }}</dt> <dt>{{ entry.label }}</dt>
@@ -83,8 +99,11 @@ const legalNotes = [
</dl> </dl>
</article> </article>
<article class="detail-card hoard-panel"> <article class="detail-card ui-panel">
<h2>Register und Steuer</h2> <header class="detail-card__head">
<span class="ui-icon-tile"><v-icon icon="mdi-clipboard-text-outline" size="20" /></span>
<h2>Register &amp; Steuer</h2>
</header>
<dl class="detail-list"> <dl class="detail-list">
<div v-for="entry in registerDetails" :key="entry.label" class="detail-item"> <div v-for="entry in registerDetails" :key="entry.label" class="detail-item">
<dt>{{ entry.label }}</dt> <dt>{{ entry.label }}</dt>
@@ -94,16 +113,22 @@ const legalNotes = [
</article> </article>
</section> </section>
<section class="notes-section hoard-panel"> <section class="notes-section ui-panel">
<header class="notes-head"> <header class="ui-section-head">
<p class="notes-kicker hoard-kicker">Rechtliche Hinweise</p> <p class="ui-kicker">Rechtliche Hinweise</p>
<h2>Wichtige Zusatzinformationen</h2> <h2>Wichtige Zusatzinformationen</h2>
<p>Standardklauseln, die im Produktivbetrieb durch eine juristische Prüfung ersetzt werden sollten.</p>
</header> </header>
<div class="notes-grid"> <div class="notes-grid">
<article v-for="note in legalNotes" :key="note.title" class="note-card"> <article v-for="note in legalNotes" :key="note.title" class="note-card">
<h3>{{ note.title }}</h3> <span class="ui-icon-tile">
<p>{{ note.text }}</p> <v-icon :icon="note.icon" size="20" />
</span>
<div>
<h3>{{ note.title }}</h3>
<p>{{ note.text }}</p>
</div>
</article> </article>
</div> </div>
</section> </section>
@@ -112,66 +137,50 @@ const legalNotes = [
<style scoped> <style scoped>
.impressum-page { .impressum-page {
--hoard-page-width: 1120px; --ui-page-width: 1180px;
} }
/* ---------- Hero ---------- */
.impressum-hero { .impressum-hero {
--hoard-gradient-angle: 125deg; --ui-gradient-angle: 130deg;
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 40%, var(--color-surface) 60%); --ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%);
--hoard-gradient-end: var(--color-surface); --ui-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 56%; --ui-gradient-end-stop: 65%;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1.2fr) auto;
gap: var(--space-6); gap: var(--space-6);
align-items: end; align-items: end;
padding: var(--space-8); padding: var(--space-10) var(--space-8);
border-radius: var(--radius-xl);
} }
.hero-lead, .impressum-hero__copy h1 {
.meta-text { margin: 0 0 var(--space-3);
font-size: clamp(2rem, 1.4rem + 1.4vw, 2.6rem);
letter-spacing: -0.02em;
}
.impressum-hero__lead {
margin: 0; margin: 0;
}
h1 {
margin-bottom: var(--space-3);
font-size: clamp(1.9rem, 2.2vw + 1rem, 2.5rem);
}
.hero-lead {
max-width: 64ch; max-width: 64ch;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: var(--font-size-md);
line-height: 1.6;
} }
.hero-meta { .impressum-hero__meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; gap: var(--space-2);
gap: var(--space-3);
margin-top: var(--space-5); margin-top: var(--space-5);
} }
.meta-pill { .impressum-hero__actions {
display: inline-flex;
align-items: center;
padding: 4px var(--space-3);
border: 1px solid color-mix(in srgb, var(--color-primary-300) 60%, var(--color-border) 40%);
border-radius: 999px;
color: var(--color-primary-700);
font-size: var(--font-size-sm);
font-weight: 600;
background-color: color-mix(in srgb, var(--color-primary-100) 82%, var(--color-surface) 18%);
}
.meta-text {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.hero-actions {
justify-content: flex-end; justify-content: flex-end;
} }
/* ---------- Detail cards ---------- */
.details-grid { .details-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -181,13 +190,30 @@ h1 {
.detail-card { .detail-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-4);
padding: var(--space-5); padding: var(--space-6);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
} }
h2 { .detail-card:hover {
border-color: color-mix(in srgb, var(--color-primary-300) 50%, var(--color-border) 50%);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.detail-card__head {
display: inline-flex;
align-items: center;
gap: var(--space-3);
}
.detail-card h2 {
margin: 0; margin: 0;
font-size: clamp(1.2rem, 1.2vw + 0.85rem, 1.6rem); font-size: var(--font-size-xl);
letter-spacing: -0.01em;
} }
.detail-list { .detail-list {
@@ -199,7 +225,7 @@ h2 {
.detail-item { .detail-item {
padding-bottom: var(--space-3); padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 75%, var(--color-surface) 25%); border-bottom: 1px solid var(--color-border-subtle);
} }
.detail-item:last-child { .detail-item:last-child {
@@ -210,21 +236,21 @@ h2 {
dt { dt {
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--font-size-sm); font-size: var(--font-size-2xs);
font-weight: 600; font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
} }
dd { dd {
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
font-size: var(--font-size-md);
} }
/* ---------- Notes ---------- */
.notes-section { .notes-section {
padding: var(--space-6); padding: var(--space-7);
}
.notes-head {
margin-bottom: var(--space-5);
} }
.notes-grid { .notes-grid {
@@ -234,15 +260,31 @@ dd {
} }
.note-card { .note-card {
padding: var(--space-4); display: grid;
border: 1px solid color-mix(in srgb, var(--color-border) 76%, var(--color-surface) 24%); grid-template-columns: auto 1fr;
gap: var(--space-4);
align-items: flex-start;
padding: var(--space-5);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 72%, var(--color-surface) 28%); background-color: color-mix(in srgb, var(--color-surface-alt) 60%, var(--color-surface) 40%);
transition:
border-color var(--transition-fast),
background-color var(--transition-fast),
transform var(--transition-fast);
} }
h3 { .note-card:hover {
margin: 0 0 var(--space-2); border-color: color-mix(in srgb, var(--color-primary-300) 40%, var(--color-border) 60%);
font-size: 1.03rem; background-color: var(--color-surface);
transform: translateY(-2px);
}
.note-card h3 {
margin: 0 0 var(--space-1);
font-size: var(--font-size-lg);
font-weight: 600;
letter-spacing: -0.01em;
} }
.note-card p { .note-card p {
@@ -250,6 +292,18 @@ h3 {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@media (prefers-reduced-motion: no-preference) {
.impressum-hero,
.detail-card,
.notes-section {
animation: ui-soft-enter 280ms both;
}
.detail-card:nth-child(2) { animation-delay: 80ms; }
.detail-card:nth-child(3) { animation-delay: 140ms; }
.notes-section { animation-delay: 200ms; }
}
@media (width <= 1080px) { @media (width <= 1080px) {
.details-grid { .details-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -257,19 +311,14 @@ h3 {
} }
@media (width <= 960px) { @media (width <= 960px) {
.impressum-hero,
.notes-section {
padding: var(--space-5);
}
.impressum-hero { .impressum-hero {
grid-template-columns: 1fr; grid-template-columns: 1fr;
align-items: start; align-items: start;
padding: var(--space-7);
} }
.hero-actions { .impressum-hero__actions {
justify-content: flex-start; justify-content: flex-start;
gap: var(--space-2);
} }
.details-grid, .details-grid,
@@ -277,40 +326,32 @@ h3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
:deep(.hero-actions .v-btn) { .notes-section {
min-height: 44px; padding: var(--space-6);
} }
} }
@media (width <= 600px) { @media (width <= 600px) {
.impressum-hero, .impressum-hero,
.notes-section { .notes-section,
padding: var(--space-4);
}
h1 {
font-size: clamp(1.55rem, 7vw, 1.95rem);
}
.hero-meta {
margin-top: var(--space-4);
gap: var(--space-2);
}
.hero-actions {
width: 100%;
}
:deep(.hero-actions .v-btn) {
width: 100%;
}
.detail-card { .detail-card {
padding: var(--space-4); padding: var(--space-5);
}
.impressum-hero__actions {
width: 100%;
}
:deep(.impressum-hero__actions .v-btn) {
width: 100%;
}
.impressum-hero__meta {
margin-top: var(--space-4);
} }
.note-card { .note-card {
padding: var(--space-3); padding: var(--space-4);
} }
} }
</style> </style>
+392
View File
@@ -0,0 +1,392 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchAdminUserById, type AdminUser } from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
import StatusPill from '@/components/ui/StatusPill.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
import EditUserDialog from '@/components/admin/EditUserDialog.vue'
const route = useRoute()
const router = useRouter()
const appBannersStore = useAppBannersStore()
const isLoading = ref(true)
const errorMessage = ref('')
const user = ref<AdminUser | null>(null)
const isEditDialogOpen = ref(false)
const routeUserId = computed(() => {
const value = route.params.userId
return typeof value === 'string' ? value : ''
})
const userInitials = computed(() => {
const name = user.value?.userName?.trim() ?? ''
if (name.length === 0) {
return '·'
}
const parts = name.split(/[\s._-]+/).filter((part) => part.length > 0)
if (parts.length === 0) {
return name.slice(0, 2).toUpperCase()
}
if (parts.length === 1) {
const first = parts[0] as string
return first.slice(0, 2).toUpperCase()
}
const first = parts[0] as string
const second = parts[1] as string
return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase()
})
const rolesText = computed(() => {
if (!user.value || user.value.roles.length === 0) {
return 'Keine Rolle'
}
return user.value.roles.join(', ')
})
async function loadUser() {
isLoading.value = true
errorMessage.value = ''
if (routeUserId.value.length === 0) {
errorMessage.value = 'Ungültige Benutzer-ID in der Route.'
isLoading.value = false
return
}
try {
user.value = await fetchAdminUserById(routeUserId.value)
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
if (error.status === 401) {
await router.replace({
name: 'Login',
query: { redirect: route.fullPath },
})
return
}
if (error.status === 403) {
await router.replace({ name: 'Forbidden' })
return
}
} else {
errorMessage.value = 'Benutzerdetails konnten nicht geladen werden.'
}
appBannersStore.pushError(errorMessage.value, 'Benutzerdetails')
} finally {
isLoading.value = false
}
}
async function navigateToList() {
await router.push({ name: 'AdminUsers' })
}
function handleUserUpdated(updated: AdminUser) {
user.value = updated
}
onMounted(() => {
void loadUser()
})
</script>
<template>
<v-container fluid class="admin-user-detail-page ui-page">
<header class="admin-user-detail-head ui-panel ui-panel-gradient">
<div class="admin-user-detail-head__copy">
<p class="ui-kicker ui-kicker--wide">Adminbereich</p>
<h1>Benutzerdetails</h1>
<p>Detailansicht des ausgewählten Kontos. Über <strong>Bearbeiten</strong> lassen sich Benutzername und Status anpassen.</p>
</div>
<div class="admin-user-detail-head__actions ui-action-row">
<v-btn
variant="outlined"
prepend-icon="mdi-arrow-left"
@click="navigateToList"
>
Zurück zur Liste
</v-btn>
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@click="loadUser"
>
Neu laden
</v-btn>
<v-btn
variant="elevated"
color="primary"
prepend-icon="mdi-pencil"
:disabled="isLoading || !user"
@click="isEditDialogOpen = true"
>
Bearbeiten
</v-btn>
</div>
</header>
<v-alert v-if="errorMessage" type="error" density="comfortable">
{{ errorMessage }}
</v-alert>
<p v-else-if="isLoading" class="admin-user-detail-loading">Benutzerdetails werden geladen </p>
<article v-else-if="user" class="admin-user-detail-card ui-panel">
<header class="admin-user-detail-card__head">
<UserAvatar class="admin-user-detail-avatar" :initials="userInitials" />
<div>
<p class="ui-kicker ui-kicker--xs">Konto</p>
<h2>{{ user.userName || '(ohne Benutzername)' }}</h2>
<p class="admin-user-detail-card__id">{{ user.id }}</p>
</div>
<div class="admin-user-detail-card__pills">
<StatusPill :variant="user.isActive ? 'success' : 'danger'">
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</StatusPill>
<StatusPill :variant="user.mustChangePassword ? 'warning' : 'info'">
{{ user.mustChangePassword ? 'Passwort wechseln' : 'Passwort aktuell' }}
</StatusPill>
</div>
</header>
<hr class="ui-divider-soft" />
<dl class="admin-user-detail-grid">
<div class="admin-user-detail-item">
<dt>ID</dt>
<dd class="admin-user-detail-item__mono">{{ user.id }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Benutzername</dt>
<dd>{{ user.userName || '(ohne Benutzername)' }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Rollen</dt>
<dd>{{ rolesText }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Konto aktiv</dt>
<dd>{{ user.isActive ? 'Ja' : 'Nein' }}</dd>
</div>
<div class="admin-user-detail-item">
<dt>Passwortwechsel</dt>
<dd>{{ user.mustChangePassword ? 'Erforderlich' : 'Nicht erforderlich' }}</dd>
</div>
</dl>
</article>
<EditUserDialog
v-if="user"
v-model="isEditDialogOpen"
:user="user"
@updated="handleUserUpdated"
/>
</v-container>
</template>
<style scoped>
.admin-user-detail-page {
--ui-page-width: 1080px;
}
.admin-user-detail-head {
--ui-gradient-angle: 120deg;
--ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 55%, var(--color-surface) 45%);
--ui-gradient-end: var(--color-surface);
--ui-gradient-end-stop: 65%;
display: grid;
grid-template-columns: minmax(0, 1.1fr) auto;
gap: var(--space-5);
align-items: end;
padding: var(--space-7) var(--space-8);
border-radius: var(--radius-xl);
}
.admin-user-detail-head__copy h1 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.admin-user-detail-head__copy p {
margin: 0;
color: var(--color-text-secondary);
}
.admin-user-detail-loading {
margin: 0;
color: var(--color-text-secondary);
}
.admin-user-detail-card {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
}
.admin-user-detail-card__head {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--space-4);
align-items: center;
}
.admin-user-detail-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-primary-600), var(--color-primary-800));
color: var(--color-text-on-primary);
font-size: 22px;
font-weight: 700;
letter-spacing: 0.02em;
box-shadow:
var(--shadow-sm),
inset 0 0 0 2px color-mix(in srgb, var(--color-accent-lime) 22%, transparent);
}
.admin-user-detail-card__head h2 {
margin: var(--space-1) 0;
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.admin-user-detail-card__id {
margin: 0;
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
overflow-wrap: anywhere;
}
.admin-user-detail-card__pills {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: flex-end;
}
.admin-user-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: var(--space-4) var(--space-6);
margin: 0;
}
.admin-user-detail-item {
display: grid;
gap: var(--space-1);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.admin-user-detail-item:nth-last-child(-n + 2) {
border-bottom: none;
padding-bottom: 0;
}
.admin-user-detail-item dt {
color: var(--color-text-muted);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-user-detail-item dd {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-md);
word-break: break-word;
}
.admin-user-detail-item__mono {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm) !important;
}
@media (prefers-reduced-motion: no-preference) {
.admin-user-detail-head,
.admin-user-detail-card {
animation: ui-soft-enter 280ms both;
}
.admin-user-detail-card {
animation-delay: 80ms;
}
}
@media (width <= 960px) {
.admin-user-detail-head {
grid-template-columns: 1fr;
align-items: flex-start;
padding: var(--space-6);
}
.admin-user-detail-head__actions {
width: 100%;
justify-content: flex-start;
}
.admin-user-detail-card__head {
grid-template-columns: auto 1fr;
}
.admin-user-detail-card__pills {
grid-column: 1 / -1;
flex-direction: row;
align-items: flex-start;
flex-wrap: wrap;
}
}
@media (width <= 600px) {
.admin-user-detail-head {
padding: var(--space-5);
}
.admin-user-detail-card {
padding: var(--space-5);
}
.admin-user-detail-card__head {
grid-template-columns: auto 1fr;
gap: var(--space-3);
}
.admin-user-detail-grid {
grid-template-columns: 1fr;
}
.admin-user-detail-item:nth-last-child(-n + 2) {
border-bottom: 1px solid var(--color-border-subtle);
padding-bottom: var(--space-3);
}
.admin-user-detail-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
</style>
+673
View File
@@ -0,0 +1,673 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { fetchAdminUsers, type AdminUser } from '@/services/adminUsers'
import { AuthRequestError } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
import StatusPill from '@/components/ui/StatusPill.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import CreateUserDialog from '@/components/admin/CreateUserDialog.vue'
const router = useRouter()
const appBannersStore = useAppBannersStore()
const isLoading = ref(true)
const errorMessage = ref('')
const users = ref<AdminUser[]>([])
const searchQuery = ref('')
const isCreateDialogOpen = ref(false)
const hasUsers = computed(() => users.value.length > 0)
const activeUserCount = computed(() => users.value.filter((user) => user.isActive).length)
const passwordChangeCount = computed(
() => users.value.filter((user) => user.mustChangePassword).length,
)
const adminCount = computed(
() =>
users.value.filter((user) =>
user.roles.some((role) => role.toLowerCase() === 'admin'),
).length,
)
const filteredUsers = computed(() => {
const query = (searchQuery.value ?? '').trim().toLowerCase()
if (query.length === 0) {
return users.value
}
return users.value.filter((user) => {
if (user.userName.toLowerCase().includes(query)) {
return true
}
if (user.id.toLowerCase().includes(query)) {
return true
}
return user.roles.some((role) => role.toLowerCase().includes(query))
})
})
function userInitials(user: AdminUser) {
const name = user.userName?.trim() ?? ''
if (name.length === 0) {
return '·'
}
const parts = name.split(/[\s._-]+/).filter((part) => part.length > 0)
if (parts.length === 0) {
return name.slice(0, 2).toUpperCase()
}
if (parts.length === 1) {
const first = parts[0] as string
return first.slice(0, 2).toUpperCase()
}
const first = parts[0] as string
const second = parts[1] as string
return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase()
}
function formatRoles(roles: string[]): string {
return roles.length > 0 ? roles.join(', ') : 'Keine Rolle'
}
async function loadUsers() {
isLoading.value = true
errorMessage.value = ''
try {
users.value = await fetchAdminUsers()
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
if (error.status === 401) {
await router.replace({
name: 'Login',
query: { redirect: '/admin/users' },
})
return
}
if (error.status === 403) {
await router.replace({ name: 'Forbidden' })
return
}
} else {
errorMessage.value = 'Benutzerliste konnte nicht geladen werden.'
}
appBannersStore.pushError(errorMessage.value, 'Benutzerverwaltung')
} finally {
isLoading.value = false
}
}
async function openUserDetail(userId: string) {
await router.push({ name: 'AdminUserDetail', params: { userId } })
}
function openCreateDialog() {
isCreateDialogOpen.value = true
}
function handleUserCreated(user: AdminUser) {
users.value = [...users.value, user]
}
onMounted(() => {
void loadUsers()
})
</script>
<template>
<v-container fluid class="admin-users-page ui-page">
<header class="admin-users-header ui-panel ui-panel-gradient">
<div class="admin-users-header__copy">
<p class="ui-kicker ui-kicker--wide">Adminbereich</p>
<h1>Benutzerverwaltung</h1>
<p>Alle Hoard-Konten mit Rollen, Status und Passwortwechselpflicht read-only.</p>
</div>
<div class="admin-users-header__actions ui-action-row">
<v-text-field
v-model="searchQuery"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-magnify"
placeholder="Benutzer, Rollen oder ID suchen"
hide-details
clearable
class="admin-users-search"
/>
<v-btn
variant="elevated"
color="primary"
prepend-icon="mdi-account-plus-outline"
:disabled="isLoading"
@click="openCreateDialog"
>
Benutzer anlegen
</v-btn>
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@click="loadUsers"
>
Neu laden
</v-btn>
</div>
</header>
<section v-if="!isLoading && !errorMessage" class="admin-users-stats" aria-label="Benutzerübersicht">
<article class="admin-users-stat">
<span class="ui-icon-tile">
<v-icon icon="mdi-account-group-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Konten</p>
<p class="admin-users-stat__value">{{ users.length }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="ui-icon-tile">
<v-icon icon="mdi-account-check-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Aktiv</p>
<p class="admin-users-stat__value">{{ activeUserCount }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="ui-icon-tile">
<v-icon icon="mdi-shield-account-outline" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Admins</p>
<p class="admin-users-stat__value">{{ adminCount }}</p>
</div>
</article>
<article class="admin-users-stat">
<span class="ui-icon-tile">
<v-icon icon="mdi-lock-reset" size="20" />
</span>
<div>
<p class="admin-users-stat__label">Passwortwechsel</p>
<p class="admin-users-stat__value">{{ passwordChangeCount }}</p>
</div>
</article>
</section>
<v-alert v-if="errorMessage" type="error">
{{ errorMessage }}
</v-alert>
<p v-else-if="isLoading" class="admin-users-loading">Benutzer werden geladen </p>
<EmptyState
v-else-if="!hasUsers"
icon="mdi-account-question-outline"
title="Keine Benutzer gefunden"
>
Aktuell sind keine Konten vorhanden. Ein neuer Account muss vom Admin manuell angelegt werden.
</EmptyState>
<section v-else class="admin-users-listing ui-panel">
<header class="admin-users-listing__head ui-toolbar">
<div>
<p class="admin-users-listing__title">Benutzer</p>
<p class="admin-users-listing__meta">{{ filteredUsers.length }} von {{ users.length }} angezeigt</p>
</div>
</header>
<p v-if="filteredUsers.length === 0" class="admin-users-empty-search">
Keine Treffer für {{ searchQuery }}".
</p>
<div v-else class="admin-users-table-wrap">
<v-table class="admin-users-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Rollen</th>
<th>Aktiv</th>
<th>Passwort</th>
<th class="admin-users-col-actions">Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>
<div class="admin-users-cell-user">
<UserAvatar class="admin-users-cell-user__avatar" :initials="userInitials(user)" />
<div>
<p class="admin-users-cell-user__name">{{ user.userName || '(ohne Benutzername)' }}</p>
<p class="admin-users-cell-user__id">{{ user.id }}</p>
</div>
</div>
</td>
<td>{{ formatRoles(user.roles) }}</td>
<td>
<StatusPill :variant="user.isActive ? 'success' : 'danger'">
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</StatusPill>
</td>
<td>
<StatusPill :variant="user.mustChangePassword ? 'warning' : 'info'">
{{ user.mustChangePassword ? 'Erforderlich' : 'Aktuell' }}
</StatusPill>
</td>
<td class="admin-users-col-actions">
<v-btn
size="small"
variant="outlined"
prepend-icon="mdi-arrow-right"
@click="openUserDetail(user.id)"
>
Details
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</div>
<div v-if="filteredUsers.length > 0" class="admin-users-mobile-list" aria-label="Benutzerliste">
<article v-for="user in filteredUsers" :key="user.id" class="admin-users-mobile-card">
<header class="admin-users-mobile-head">
<UserAvatar class="admin-users-mobile-avatar" :initials="userInitials(user)" />
<div>
<p class="admin-users-mobile-label">Benutzer</p>
<h2>{{ user.userName || '(ohne Benutzername)' }}</h2>
<p class="admin-users-mobile-id">{{ user.id }}</p>
</div>
</header>
<dl class="admin-users-mobile-details">
<div>
<dt>Rollen</dt>
<dd>{{ formatRoles(user.roles) }}</dd>
</div>
<div>
<dt>Aktiv</dt>
<dd>
<StatusPill :variant="user.isActive ? 'success' : 'danger'">
{{ user.isActive ? 'Aktiv' : 'Inaktiv' }}
</StatusPill>
</dd>
</div>
<div>
<dt>Passwortwechsel</dt>
<dd>
<StatusPill :variant="user.mustChangePassword ? 'warning' : 'info'">
{{ user.mustChangePassword ? 'Erforderlich' : 'Nein' }}
</StatusPill>
</dd>
</div>
</dl>
<v-btn
variant="outlined"
prepend-icon="mdi-arrow-right"
block
@click="openUserDetail(user.id)"
>
Details öffnen
</v-btn>
</article>
</div>
</section>
<CreateUserDialog v-model="isCreateDialogOpen" @created="handleUserCreated" />
</v-container>
</template>
<style scoped>
.admin-users-page {
--ui-page-width: 1200px;
}
/* ---------- Header ---------- */
.admin-users-header {
--ui-gradient-angle: 120deg;
--ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 55%, var(--color-surface) 45%);
--ui-gradient-end: var(--color-surface);
--ui-gradient-end-stop: 65%;
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: var(--space-6);
align-items: end;
padding: var(--space-7) var(--space-8);
border-radius: var(--radius-xl);
}
.admin-users-header__copy h1 {
margin: 0 0 var(--space-2);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.admin-users-header__copy p {
margin: 0;
color: var(--color-text-secondary);
}
.admin-users-header__actions {
justify-content: flex-end;
align-items: center;
}
.admin-users-search {
flex: 1 1 280px;
min-width: 220px;
max-width: 380px;
}
/* ---------- Stats ---------- */
.admin-users-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}
.admin-users-stat {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 95%, var(--color-surface-alt) 5%),
var(--color-surface)
);
box-shadow: var(--shadow-xs);
}
.admin-users-stat__label,
.admin-users-stat__value {
margin: 0;
}
.admin-users-stat__label {
color: var(--color-text-muted);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-users-stat__value {
color: var(--color-text);
font-size: var(--font-size-2xl);
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.1;
}
/* ---------- Listing ---------- */
.admin-users-listing {
padding: 0;
overflow: hidden;
}
.admin-users-listing__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.admin-users-listing__title {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 600;
}
.admin-users-listing__meta {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.admin-users-empty-search {
margin: 0;
padding: var(--space-6);
color: var(--color-text-secondary);
text-align: center;
}
.admin-users-loading {
margin: 0;
color: var(--color-text-secondary);
}
.admin-users-table-wrap {
overflow-x: auto;
}
.admin-users-mobile-list {
display: none;
}
.admin-users-table {
min-width: 920px;
border: 0;
border-radius: 0;
}
.admin-users-cell-user {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
min-width: 0;
}
.admin-users-cell-user__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-primary-600), var(--color-primary-800));
color: var(--color-text-on-primary);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: 0.02em;
}
.admin-users-cell-user__name,
.admin-users-cell-user__id {
margin: 0;
}
.admin-users-cell-user__name {
color: var(--color-text);
font-weight: 600;
}
.admin-users-cell-user__id {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-2xs);
overflow-wrap: anywhere;
}
.admin-users-col-actions {
text-align: right;
white-space: nowrap;
}
.ui-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
@media (prefers-reduced-motion: no-preference) {
.admin-users-header,
.admin-users-stat,
.admin-users-listing {
animation: ui-soft-enter 280ms both;
}
.admin-users-stat:nth-child(2) { animation-delay: 60ms; }
.admin-users-stat:nth-child(3) { animation-delay: 120ms; }
.admin-users-stat:nth-child(4) { animation-delay: 180ms; }
.admin-users-listing { animation-delay: 220ms; }
}
@media (width <= 1100px) {
.admin-users-header {
grid-template-columns: 1fr;
align-items: flex-start;
}
.admin-users-header__actions {
justify-content: flex-start;
}
.admin-users-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 960px) {
.admin-users-header {
padding: var(--space-6);
}
}
@media (width <= 600px) {
.admin-users-header {
padding: var(--space-5);
}
.admin-users-search {
flex: 1 1 100%;
max-width: none;
}
.admin-users-stats {
grid-template-columns: 1fr;
}
.admin-users-listing {
overflow: hidden;
}
.admin-users-table-wrap {
display: none;
}
.admin-users-mobile-list {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
}
.admin-users-mobile-card {
display: grid;
gap: var(--space-4);
min-width: 0;
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-surface);
box-shadow: var(--shadow-xs);
}
.admin-users-mobile-head {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
min-width: 0;
}
.admin-users-mobile-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-primary-600), var(--color-primary-800));
color: var(--color-text-on-primary);
font-size: var(--font-size-sm);
font-weight: 700;
}
.admin-users-mobile-label,
.admin-users-mobile-head h2,
.admin-users-mobile-id,
.admin-users-mobile-details {
margin: 0;
}
.admin-users-mobile-label {
color: var(--color-text-muted);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.admin-users-mobile-head h2 {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
overflow-wrap: anywhere;
}
.admin-users-mobile-id {
color: var(--color-text-muted);
font-family: var(--font-family-mono);
font-size: var(--font-size-2xs);
overflow-wrap: anywhere;
}
.admin-users-mobile-details {
display: grid;
gap: var(--space-3);
}
.admin-users-mobile-details div {
display: grid;
gap: var(--space-1);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.admin-users-mobile-details div:last-child {
padding-bottom: 0;
border-bottom: none;
}
.admin-users-mobile-details dt {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.admin-users-mobile-details dd {
margin: 0;
color: var(--color-text);
overflow-wrap: anywhere;
}
}
</style>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AuthRequestError, changePassword } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
const router = useRouter()
const appBannersStore = useAppBannersStore()
const oldPassword = ref('')
const newPassword = ref('')
const newPasswordConfirm = ref('')
const showOldPassword = ref(false)
const showNewPassword = ref(false)
const showNewPasswordConfirm = ref(false)
const isSubmitting = ref(false)
const errorMessage = ref('')
const submitDisabled = computed(() => {
return (
isSubmitting.value ||
oldPassword.value.length === 0 ||
newPassword.value.length === 0 ||
newPasswordConfirm.value.length === 0
)
})
const passwordsMatch = computed(() => {
if (newPassword.value.length === 0 || newPasswordConfirm.value.length === 0) {
return null
}
return newPassword.value === newPasswordConfirm.value
})
const passwordHints = computed(() => [
{
label: 'Mindestens 8 Zeichen',
valid: newPassword.value.length >= 8,
},
{
label: 'Buchstabe enthalten',
valid: /[A-Za-zÄÖÜäöüß]/.test(newPassword.value),
},
{
label: 'Ziffer enthalten',
valid: /\d/.test(newPassword.value),
},
{
label: 'Übereinstimmung mit Bestätigung',
valid: passwordsMatch.value === true,
},
])
async function handleSubmit() {
if (submitDisabled.value) {
errorMessage.value = 'Bitte alle Passwortfelder ausfüllen.'
return
}
if (newPassword.value !== newPasswordConfirm.value) {
errorMessage.value = 'Die neuen Passwörter stimmen nicht überein.'
return
}
isSubmitting.value = true
errorMessage.value = ''
try {
await changePassword({
oldPassword: oldPassword.value,
newPassword: newPassword.value,
newPasswordConfirm: newPasswordConfirm.value,
})
await router.replace({
name: 'Login',
query: { passwordChanged: '1' },
})
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
if (error.status === 401 || error.status === 403) {
appBannersStore.pushError('Session abgelaufen. Bitte erneut anmelden.', 'Passwort ändern')
await router.replace({ name: 'Login' })
return
}
} else {
errorMessage.value = 'Passwortänderung fehlgeschlagen. Bitte versuche es erneut.'
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<v-container fluid class="change-password-page ui-page ui-page--centered">
<section class="change-password-shell ui-panel ui-shell-grid">
<header class="change-password-head">
<span class="ui-icon-tile ui-icon-tile--lg">
<v-icon icon="mdi-shield-key-outline" size="24" />
</span>
<div>
<p class="ui-kicker ui-kicker--xs">Sicherheitsvorgabe</p>
<h1>Passwort ändern</h1>
<p>Aktualisiere dein Hoard-Passwort. Nach der Änderung wirst du erneut zur Anmeldung weitergeleitet.</p>
</div>
</header>
<v-alert
v-if="errorMessage"
type="error"
density="comfortable"
>
{{ errorMessage }}
</v-alert>
<v-form class="change-password-form" @submit.prevent="handleSubmit">
<section class="change-password-section">
<p class="ui-kicker ui-kicker--xs ui-kicker--plain">Aktuell</p>
<v-text-field
v-model="oldPassword"
label="Altes Passwort"
:type="showOldPassword ? 'text' : 'password'"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showOldPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
required
:disabled="isSubmitting"
@click:append-inner="showOldPassword = !showOldPassword"
/>
</section>
<hr class="ui-divider-soft" />
<section class="change-password-section">
<p class="ui-kicker ui-kicker--xs ui-kicker--plain">Neues Passwort</p>
<v-text-field
v-model="newPassword"
label="Neues Passwort"
:type="showNewPassword ? 'text' : 'password'"
prepend-inner-icon="mdi-lock-reset"
:append-inner-icon="showNewPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
required
:disabled="isSubmitting"
@click:append-inner="showNewPassword = !showNewPassword"
/>
<v-text-field
v-model="newPasswordConfirm"
label="Neues Passwort bestätigen"
:type="showNewPasswordConfirm ? 'text' : 'password'"
prepend-inner-icon="mdi-lock-check-outline"
:append-inner-icon="showNewPasswordConfirm ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="new-password"
required
:disabled="isSubmitting"
:error="passwordsMatch === false"
:error-messages="
passwordsMatch === false ? 'Bestätigung stimmt nicht mit dem neuen Passwort überein.' : undefined
"
@click:append-inner="showNewPasswordConfirm = !showNewPasswordConfirm"
/>
<ul class="password-hints">
<li
v-for="hint in passwordHints"
:key="hint.label"
:class="['password-hint', { 'password-hint--valid': hint.valid }]"
>
<v-icon :icon="hint.valid ? 'mdi-check-circle' : 'mdi-circle-outline'" size="14" />
{{ hint.label }}
</li>
</ul>
</section>
<p class="change-password-hint">
<v-icon icon="mdi-information-outline" size="14" />
Nach erfolgreicher Änderung wirst du automatisch abgemeldet und meldest dich anschließend mit deinem
neuen Passwort wieder an.
</p>
<v-btn
type="submit"
variant="elevated"
size="large"
prepend-icon="mdi-content-save-outline"
:loading="isSubmitting"
:disabled="submitDisabled"
block
>
Passwort speichern
</v-btn>
</v-form>
</section>
</v-container>
</template>
<style scoped>
.change-password-page {
--ui-shell-width: min(720px, 100%);
}
.change-password-shell {
gap: var(--space-5);
}
.change-password-head {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-4);
align-items: flex-start;
}
.change-password-head h1 {
margin: var(--space-1) 0 var(--space-2);
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.change-password-head p {
margin: 0;
color: var(--color-text-secondary);
}
.change-password-form {
display: grid;
gap: var(--space-4);
}
.change-password-section {
display: grid;
gap: var(--space-3);
}
.password-hints {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-2);
padding: 0;
margin: 0;
list-style: none;
}
.password-hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.3;
transition: color var(--transition-fast);
}
.password-hint--valid {
color: var(--color-primary-700);
}
.password-hint .v-icon {
flex: 0 0 auto;
}
.change-password-hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: var(--space-3);
border-radius: var(--radius-sm);
background-color: color-mix(in srgb, var(--color-surface-alt) 80%, var(--color-surface) 20%);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.45;
}
.change-password-hint .v-icon {
color: var(--color-primary-700);
flex: 0 0 auto;
}
@media (prefers-reduced-motion: no-preference) {
.change-password-shell {
animation: ui-soft-enter 280ms both;
}
}
@media (width <= 600px) {
.change-password-head {
grid-template-columns: 1fr;
}
.password-hints {
grid-template-columns: 1fr;
}
}
</style>
+316 -100
View File
@@ -1,96 +1,268 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import iconImage from '@/assets/images/icon.svg'
import { AuthRequestError, login } from '@/services/authSession'
import { useAppBannersStore } from '@/stores/appBanners'
const showPassword = ref(false) const showPassword = ref(false)
const userName = ref('')
const password = ref('')
const isSubmitting = ref(false)
const appBannersStore = useAppBannersStore()
const route = useRoute()
const router = useRouter()
const submitDisabled = computed(
() => isSubmitting.value || userName.value.trim().length === 0 || password.value.length === 0,
)
const redirectPath = computed(() => {
const redirectQuery = route.query.redirect
if (typeof redirectQuery === 'string' && redirectQuery.startsWith('/')) {
return redirectQuery
}
return '/'
})
async function handleSubmit() {
if (submitDisabled.value) {
appBannersStore.pushError('Bitte Benutzername und Passwort eingeben.', 'Anmeldung fehlgeschlagen')
return
}
isSubmitting.value = true
try {
await login({
userName: userName.value.trim(),
password: password.value,
})
await router.replace(redirectPath.value)
} catch (error) {
if (error instanceof AuthRequestError) {
appBannersStore.pushError(error.message, 'Anmeldung fehlgeschlagen')
} else {
appBannersStore.pushError(
'Anmeldung fehlgeschlagen. Bitte versuche es erneut.',
'Anmeldung fehlgeschlagen',
)
}
} finally {
isSubmitting.value = false
}
}
onMounted(() => {
const passwordChangedFlag = route.query.passwordChanged
if (passwordChangedFlag !== '1' && passwordChangedFlag !== 'true') {
return
}
appBannersStore.push({
type: 'success',
title: 'Passwort geändert',
message: 'Bitte melde dich mit deinem neuen Passwort an.',
})
const nextQuery = Object.fromEntries(
Object.entries(route.query).filter(([key]) => key !== 'passwordChanged'),
)
void router.replace({
query: nextQuery,
})
})
</script> </script>
<template> <template>
<v-container fluid class="login-page hoard-page hoard-page--centered"> <v-container fluid class="login-page ui-page ui-page--centered">
<section class="login-shell hoard-panel hoard-shell-grid hoard-panel-gradient"> <section class="login-shell ui-panel ui-panel-gradient ui-spotlight">
<aside class="login-brand"> <aside class="login-brand">
<p class="login-kicker hoard-kicker hoard-kicker--wide">Willkommen bei Hoard</p> <div class="login-brand__logo">
<h1>Anmelden und weiterarbeiten</h1> <span class="login-brand__halo" aria-hidden="true" />
<img :src="iconImage" alt="Hoard Icon" />
</div>
<p class="ui-kicker ui-kicker--wide">Willkommen bei Hoard</p>
<h1>
Deine Dateien.<br />
<span class="login-brand__accent">Aufgeräumt.</span>
</h1>
<p class="login-intro"> <p class="login-intro">
Deine Dateiablage bleibt aufgeräumt, schnell und direkt im Browser bedienbar. Hoard ist deine ruhige, self-hosted Dateiablage schnell, übersichtlich und direkt im Browser.
Melde dich an und mach weiter, wo du aufgehört hast.
</p> </p>
<ul class="login-points"> <ul class="login-points">
<li> <li>
<v-icon icon="mdi-folder-outline" size="18" /> <span class="ui-icon-tile"><v-icon icon="mdi-folder-outline" size="18" /></span>
Ordner und Dateien zentral verwalten <div>
<p class="login-points__title">Ordner und Dateien</p>
<p class="login-points__text">Zentral organisieren, schnell finden, sauber strukturieren.</p>
</div>
</li> </li>
<li> <li>
<v-icon icon="mdi-file-document-edit-outline" size="18" /> <span class="ui-icon-tile"><v-icon icon="mdi-language-markdown-outline" size="18" /></span>
Markdown-Dateien sofort bearbeiten <div>
<p class="login-points__title">Markdown direkt im Browser</p>
<p class="login-points__text">Notizen lesen und bearbeiten, ohne externes Tool.</p>
</div>
</li> </li>
<li> <li>
<v-icon icon="mdi-image-outline" size="18" /> <span class="ui-icon-tile"><v-icon icon="mdi-shield-check-outline" size="18" /></span>
Bilder und PDFs direkt als Vorschau ansehen <div>
<p class="login-points__title">Self-hosted &amp; sicher</p>
<p class="login-points__text">Cookie-Auth, Rollenmodell, deine Infrastruktur.</p>
</div>
</li> </li>
</ul> </ul>
</aside> </aside>
<v-form class="login-form hoard-panel" @submit.prevent> <v-form class="login-form" @submit.prevent="handleSubmit">
<div class="form-head"> <header class="login-form__head">
<h2>Login</h2> <p class="ui-kicker ui-kicker--xs">Login</p>
<p>Melde dich mit deinem bestehenden Konto an.</p> <h2>Anmelden</h2>
<p>Melde dich mit deinem bestehenden Hoard-Konto an.</p>
</header>
<div class="login-form__fields">
<v-text-field
v-model="userName"
label="Benutzername"
type="text"
prepend-inner-icon="mdi-account-outline"
autocomplete="username"
required
:disabled="isSubmitting"
/>
<v-text-field
v-model="password"
label="Passwort"
:type="showPassword ? 'text' : 'password'"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
autocomplete="current-password"
required
:disabled="isSubmitting"
@click:append-inner="showPassword = !showPassword"
/>
</div> </div>
<v-text-field <p class="login-form__hint">
label="E-Mail" <v-icon icon="mdi-cookie-outline" size="14" />
type="email" Anmeldung erfolgt per sicherem Session-Cookie. Keine Tokens, keine Drittanbieter.
variant="outlined" </p>
prepend-inner-icon="mdi-email-outline"
autocomplete="email"
required
/>
<v-text-field <v-btn
label="Passwort" type="submit"
:type="showPassword ? 'text' : 'password'" variant="elevated"
variant="outlined" block
prepend-inner-icon="mdi-lock-outline" size="large"
:append-inner-icon="showPassword ? 'mdi-eye-off-outline' : 'mdi-eye-outline'" prepend-icon="mdi-arrow-right"
autocomplete="current-password" :loading="isSubmitting"
required :disabled="submitDisabled"
@click:append-inner="showPassword = !showPassword" >
/> Anmelden
</v-btn>
<div class="form-meta"> <v-btn variant="text" block to="/welcome" prepend-icon="mdi-home-outline" size="small">
<v-checkbox hide-details color="primary" density="compact" label="Angemeldet bleiben" /> Zurück zur Startseite
<v-btn variant="text" size="small">Passwort vergessen?</v-btn> </v-btn>
</div>
<v-btn type="submit" color="primary" block size="large" prepend-icon="mdi-login">Anmelden</v-btn>
<v-btn variant="outlined" block to="/" prepend-icon="mdi-home">Zur Startseite</v-btn>
</v-form> </v-form>
</section> </section>
</v-container> </v-container>
</template> </template>
<style scoped> <style scoped>
.login-shell { .login-page {
--hoard-shell-width: 1040px; --ui-centered-offset: 200px;
--hoard-gradient-angle: 115deg; }
--hoard-gradient-start: color-mix(in srgb, var(--color-primary-100) 45%, var(--color-surface) 55%);
--hoard-gradient-end: var(--color-surface);
--hoard-gradient-end-stop: 52%;
grid-template-columns: minmax(280px, 1fr) minmax(320px, 430px); .login-shell {
--ui-shell-width: 1080px;
--ui-shell-padding: 0;
--ui-shell-gap: 0;
--ui-gradient-angle: 120deg;
--ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 70%, var(--color-surface) 30%);
--ui-gradient-end: var(--color-surface);
--ui-gradient-end-stop: 60%;
display: grid;
grid-template-columns: minmax(280px, 1.1fr) minmax(320px, 460px);
align-items: stretch;
border-radius: var(--radius-xl);
overflow: hidden;
}
.login-brand {
position: relative;
padding: var(--space-10) var(--space-8);
display: flex;
flex-direction: column;
justify-content: center;
z-index: 1;
}
.login-brand__logo {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-bottom: var(--space-5);
}
.login-brand__halo {
position: absolute;
inset: -12px;
border-radius: var(--radius-full);
background:
radial-gradient(
closest-side,
color-mix(in srgb, var(--color-accent-lime) 38%, transparent),
transparent 70%
);
filter: blur(10px);
opacity: 0.7;
}
.login-brand__logo img {
position: relative;
width: 64px;
height: 64px;
object-fit: contain;
filter: drop-shadow(0 8px 18px rgb(28 101 47 / 30%));
} }
h1 { h1 {
margin-bottom: var(--space-3); margin: 0 0 var(--space-3);
max-width: 18ch; max-width: 16ch;
font-size: clamp(1.9rem, 2vw + 1rem, 2.6rem); font-size: clamp(2rem, 1.4rem + 1.5vw, 2.8rem);
font-weight: 700; font-weight: 700;
line-height: 1.05;
letter-spacing: -0.025em;
}
.login-brand__accent {
background: linear-gradient(120deg, var(--color-primary-700), var(--color-primary-500));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
.login-intro { .login-intro {
margin-bottom: var(--space-5); margin: 0 0 var(--space-6);
max-width: 44ch; max-width: 46ch;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: var(--font-size-md);
line-height: 1.6;
} }
.login-points { .login-points {
@@ -103,87 +275,131 @@ h1 {
} }
.login-points li { .login-points li {
display: flex; display: grid;
align-items: center; grid-template-columns: auto 1fr;
gap: var(--space-3); gap: var(--space-3);
align-items: flex-start;
}
.login-points__title,
.login-points__text {
margin: 0;
}
.login-points__title {
color: var(--color-text);
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.3;
}
.login-points__text {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-weight: 500; font-size: var(--font-size-sm);
line-height: 1.5;
} }
.login-form { .login-form {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
padding: var(--space-6); padding: var(--space-8);
border-radius: var(--radius-lg); border-left: 1px solid var(--color-border);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-surface) 96%, var(--color-surface-alt) 4%),
var(--color-surface)
);
z-index: 1;
} }
.form-head h2 { .login-form__head {
margin-bottom: var(--space-1); display: flex;
font-size: 1.45rem; flex-direction: column;
gap: 2px;
} }
.form-head p { .login-form__head h2 {
margin: 0;
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.login-form__head p {
margin: 0; margin: 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: var(--font-size-md);
} }
.form-meta { .login-form__fields {
display: flex; display: grid;
align-items: center;
justify-content: space-between;
gap: var(--space-3); gap: var(--space-3);
} }
.login-form__hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0;
padding: var(--space-3);
border-radius: var(--radius-sm);
background-color: color-mix(in srgb, var(--color-surface-alt) 80%, var(--color-surface) 20%);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
line-height: 1.45;
}
.login-form__hint .v-icon {
color: var(--color-primary-700);
flex: 0 0 auto;
}
@media (prefers-reduced-motion: no-preference) {
.login-brand,
.login-form {
animation: ui-soft-enter 320ms both;
}
.login-form {
animation-delay: 80ms;
}
}
@media (width <= 960px) { @media (width <= 960px) {
.login-shell { .login-shell {
display: block;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.login-brand {
padding: var(--space-7) var(--space-6) 0;
}
.login-form {
padding: var(--space-6);
border-left: none;
border-top: 1px solid var(--color-border);
}
}
@media (width <= 600px) {
.login-brand {
padding: var(--space-6) var(--space-5) 0;
}
.login-form { .login-form {
padding: var(--space-5); padding: var(--space-5);
} }
.form-meta {
flex-wrap: wrap;
}
}
@media (width <= 600px) {
h1 {
max-width: none;
font-size: clamp(1.55rem, 7vw, 1.95rem);
}
.login-intro { .login-intro {
margin-bottom: var(--space-4); margin-bottom: var(--space-5);
}
.login-points {
gap: var(--space-2);
} }
.login-points li { .login-points li {
align-items: flex-start; align-items: flex-start;
} }
.login-form {
gap: var(--space-3);
padding: var(--space-4);
}
.form-meta {
flex-direction: column;
align-items: stretch;
gap: var(--space-2);
}
:deep(.form-meta .v-btn) {
width: 100%;
min-height: 44px;
}
:deep(.login-form .v-btn) { :deep(.login-form .v-btn) {
min-height: 44px; min-height: 44px;
} }
+464
View File
@@ -0,0 +1,464 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AuthRequestError, fetchCurrentUser, type CurrentUser } from '@/services/authSession'
import StatusPill from '@/components/ui/StatusPill.vue'
import UserAvatar from '@/components/ui/UserAvatar.vue'
const router = useRouter()
const isLoading = ref(true)
const errorMessage = ref('')
const user = ref<CurrentUser | null>(null)
const prettyUser = computed(() => {
if (!user.value) {
return ''
}
return JSON.stringify(user.value, null, 2)
})
const userInitials = computed(() => {
const name = user.value?.userName?.trim() ?? ''
if (name.length === 0) {
return 'H'
}
const parts = name.split(/[\s._-]+/).filter((part) => part.length > 0)
if (parts.length === 0) {
return name.slice(0, 2).toUpperCase()
}
if (parts.length === 1) {
const first = parts[0] as string
return first.slice(0, 2).toUpperCase()
}
const first = parts[0] as string
const second = parts[1] as string
return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase()
})
const roleLabel = computed(() => {
if (!user.value || user.value.roles.length === 0) {
return 'Keine Rolle'
}
return user.value.roles.join(', ')
})
const roleChips = computed(() => {
if (!user.value || user.value.roles.length === 0) {
return [] as string[]
}
return user.value.roles
})
const accountStatusVariant = computed(() => (user.value?.isActive ? 'success' : 'danger'))
const accountStatusLabel = computed(() => (user.value?.isActive ? 'Aktiv' : 'Inaktiv'))
const passwordStatusVariant = computed(() => (user.value?.mustChangePassword ? 'warning' : 'info'))
const passwordStatusLabel = computed(() =>
user.value?.mustChangePassword ? 'Wechsel erforderlich' : 'Aktuell',
)
async function loadCurrentUser() {
isLoading.value = true
errorMessage.value = ''
try {
const currentUser = await fetchCurrentUser({ force: true })
if (!currentUser) {
await router.replace({ name: 'Login', query: { redirect: '/' } })
return
}
user.value = currentUser
} catch (error) {
if (error instanceof AuthRequestError) {
errorMessage.value = error.message
} else {
errorMessage.value = 'Benutzerdaten konnten nicht geladen werden.'
}
} finally {
isLoading.value = false
}
}
function goToChangePassword() {
void router.push({ name: 'ChangePassword' })
}
onMounted(() => {
void loadCurrentUser()
})
</script>
<template>
<v-container fluid class="dashboard-page ui-page">
<header class="dashboard-greeting ui-panel ui-panel-gradient ui-spotlight">
<div class="dashboard-greeting__copy">
<p class="ui-kicker ui-kicker--wide">Geschützter Bereich</p>
<h1>
<template v-if="user">Hallo, {{ user.userName || 'willkommen' }}.</template>
<template v-else>Dashboard</template>
</h1>
<p>
Hier siehst du den aktuellen Status deines Kontos. Sobald die Datei- und Markdown-Module live
gehen, wird dieser Bereich dein Startpunkt in den Workspace.
</p>
<div class="dashboard-greeting__chips">
<span v-for="role in roleChips" :key="role" class="ui-chip ui-chip--brand">
<v-icon icon="mdi-shield-account-outline" size="14" />
{{ role }}
</span>
<span v-if="roleChips.length === 0" class="ui-chip">
<v-icon icon="mdi-account-outline" size="14" /> Keine Rollen
</span>
</div>
</div>
<div v-if="user" class="dashboard-greeting__avatar">
<UserAvatar class="dashboard-avatar" :initials="userInitials" />
<p class="dashboard-avatar__caption">{{ user.userName }}</p>
</div>
</header>
<v-alert
v-if="errorMessage"
type="error"
density="comfortable"
>
{{ errorMessage }}
</v-alert>
<section v-else class="dashboard-grid">
<article class="dashboard-stat ui-panel">
<div class="dashboard-stat__head">
<span class="ui-icon-tile">
<v-icon icon="mdi-account-outline" size="20" />
</span>
<p class="dashboard-stat__label">Konto</p>
</div>
<p class="dashboard-stat__value">{{ user?.userName || '—' }}</p>
<p class="dashboard-stat__hint">Angemeldet als interner Benutzer</p>
</article>
<article class="dashboard-stat ui-panel">
<div class="dashboard-stat__head">
<span class="ui-icon-tile">
<v-icon icon="mdi-shield-account-outline" size="20" />
</span>
<p class="dashboard-stat__label">Rollen</p>
</div>
<p class="dashboard-stat__value">{{ roleLabel }}</p>
<p class="dashboard-stat__hint">Definiert deine sichtbaren Bereiche</p>
</article>
<article class="dashboard-stat ui-panel">
<div class="dashboard-stat__head">
<span class="ui-icon-tile">
<v-icon icon="mdi-pulse" size="20" />
</span>
<p class="dashboard-stat__label">Status</p>
</div>
<div class="dashboard-stat__pill-row">
<StatusPill :variant="accountStatusVariant">{{ accountStatusLabel }}</StatusPill>
<StatusPill :variant="passwordStatusVariant">{{ passwordStatusLabel }}</StatusPill>
</div>
<p class="dashboard-stat__hint">Konto- und Passwortzustand</p>
</article>
</section>
<section v-if="!errorMessage" class="dashboard-detail ui-panel">
<header class="dashboard-detail__head">
<div>
<p class="ui-kicker">Auth-Antwort</p>
<h2>Aktueller Session-Snapshot</h2>
<p>Direkt aus <code>GET /auth/me</code> nützlich zum Debuggen und für Plausibilitätschecks.</p>
</div>
<div class="dashboard-detail__actions ui-action-row">
<v-btn
variant="outlined"
prepend-icon="mdi-lock-reset"
@click="goToChangePassword"
>
Passwort ändern
</v-btn>
<v-btn
variant="elevated"
prepend-icon="mdi-refresh"
:loading="isLoading"
:disabled="isLoading"
@click="loadCurrentUser"
>
Neu laden
</v-btn>
</div>
</header>
<p v-if="isLoading" class="dashboard-detail__loading">Benutzerdaten werden geladen </p>
<pre v-else class="dashboard-detail__json">{{ prettyUser }}</pre>
</section>
</v-container>
</template>
<style scoped>
.dashboard-page {
--ui-page-width: 1120px;
}
/* ---------- Greeting ---------- */
.dashboard-greeting {
--ui-gradient-angle: 120deg;
--ui-gradient-start: color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%);
--ui-gradient-end: var(--color-surface);
--ui-gradient-end-stop: 65%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-6);
align-items: center;
padding: var(--space-8);
border-radius: var(--radius-xl);
overflow: hidden;
}
.dashboard-greeting__copy {
position: relative;
z-index: 1;
}
h1 {
margin: 0 0 var(--space-3);
font-size: clamp(1.8rem, 1.4rem + 1vw, 2.4rem);
letter-spacing: -0.02em;
}
.dashboard-greeting__copy p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
max-width: 64ch;
}
.dashboard-greeting__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-4);
}
.dashboard-greeting__avatar {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
z-index: 1;
}
.dashboard-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 84px;
height: 84px;
border-radius: var(--radius-full);
background:
linear-gradient(135deg, var(--color-primary-600), var(--color-primary-800));
color: var(--color-text-on-primary);
font-size: 28px;
font-weight: 700;
letter-spacing: -0.01em;
box-shadow:
var(--shadow-glow),
inset 0 0 0 2px color-mix(in srgb, var(--color-accent-lime) 25%, transparent);
}
.dashboard-avatar__caption {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------- Stats grid ---------- */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
.dashboard-stat {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
}
.dashboard-stat__head {
display: inline-flex;
align-items: center;
gap: var(--space-3);
}
.dashboard-stat__label {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-2xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.dashboard-stat__value {
margin: 0;
color: var(--color-text);
font-size: var(--font-size-xl);
font-weight: 600;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.dashboard-stat__hint {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.dashboard-stat__pill-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* ---------- Detail panel ---------- */
.dashboard-detail {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
}
.dashboard-detail__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.dashboard-detail__head h2 {
margin: 0 0 var(--space-1);
font-size: var(--font-size-xl);
letter-spacing: -0.01em;
}
.dashboard-detail__head p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.dashboard-detail__loading {
margin: 0;
color: var(--color-text-secondary);
}
.dashboard-detail__json {
margin: 0;
overflow: auto;
padding: var(--space-4);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background-color: color-mix(in srgb, var(--color-surface-alt) 70%, var(--color-surface) 30%);
color: var(--color-text);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: 1.55;
}
@media (prefers-reduced-motion: no-preference) {
.dashboard-greeting,
.dashboard-stat,
.dashboard-detail {
animation: ui-soft-enter 280ms both;
}
.dashboard-stat:nth-child(2) {
animation-delay: 60ms;
}
.dashboard-stat:nth-child(3) {
animation-delay: 120ms;
}
.dashboard-detail {
animation-delay: 160ms;
}
}
@media (width <= 960px) {
.dashboard-greeting {
grid-template-columns: 1fr;
text-align: left;
padding: var(--space-6);
}
.dashboard-greeting__avatar {
align-items: flex-start;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.dashboard-detail {
padding: var(--space-5);
}
}
@media (width <= 600px) {
.dashboard-greeting {
padding: var(--space-5);
}
.dashboard-stat,
.dashboard-detail {
padding: var(--space-4);
}
.dashboard-detail__head {
flex-direction: column;
align-items: stretch;
}
.dashboard-detail__actions {
width: 100%;
}
:deep(.dashboard-detail__actions .v-btn) {
width: 100%;
min-height: 44px;
}
.dashboard-detail__json {
max-width: 100%;
overflow-x: hidden;
padding: var(--space-3);
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
}
</style>
+352
View File
@@ -0,0 +1,352 @@
import { AuthRequestError } from '@/services/authSession'
export interface AdminUser {
id: string
userName: string
roles: string[]
isActive: boolean
mustChangePassword: boolean
}
interface ApiMessageResponse {
message?: unknown
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function normalizeAdminUser(value: unknown): AdminUser | null {
if (!isObject(value)) {
return null
}
const { id, userName, roles, isActive, mustChangePassword } = value
if (typeof id !== 'string') {
return null
}
const normalizedRoles = Array.isArray(roles)
? roles
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: []
return {
id,
userName: typeof userName === 'string' ? userName : '',
roles: normalizedRoles,
isActive: Boolean(isActive),
mustChangePassword: Boolean(mustChangePassword),
}
}
async function readApiMessage(response: Response): Promise<string | null> {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
return null
}
try {
const payload = (await response.json()) as ApiMessageResponse
if (typeof payload.message === 'string' && payload.message.trim().length > 0) {
return payload.message.trim()
}
} catch {
return null
}
return null
}
function toAdminUserError(error: unknown, fallbackMessage: string): AuthRequestError {
if (error instanceof AuthRequestError) {
return error
}
return new AuthRequestError(fallbackMessage, 0)
}
export async function fetchAdminUsers(): Promise<AdminUser[]> {
let response: Response
try {
response = await fetch('/auth/user', {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} catch (error) {
throw toAdminUserError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401) {
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', response.status)
}
if (response.status === 403) {
throw new AuthRequestError('Du bist angemeldet, aber nicht als Admin autorisiert.', response.status)
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Benutzerliste konnte nicht geladen werden.',
response.status,
)
}
const payload: unknown = await response.json()
if (!Array.isArray(payload)) {
throw new AuthRequestError('Antwortformat von /auth/user ist ungültig.', response.status)
}
const users = payload.map(normalizeAdminUser)
if (users.some((user) => user === null)) {
throw new AuthRequestError('Antwortformat von /auth/user ist ungültig.', response.status)
}
return users as AdminUser[]
}
export async function fetchAdminUserById(userId: string): Promise<AdminUser> {
const normalizedId = userId.trim()
if (normalizedId.length === 0) {
throw new AuthRequestError('Ungültige Benutzer-ID.', 400)
}
let response: Response
try {
response = await fetch(`/auth/user/${encodeURIComponent(normalizedId)}`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} catch (error) {
throw toAdminUserError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401) {
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', response.status)
}
if (response.status === 403) {
throw new AuthRequestError('Du bist angemeldet, aber nicht als Admin autorisiert.', response.status)
}
if (response.status === 404) {
throw new AuthRequestError('Benutzer wurde nicht gefunden.', response.status)
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Benutzerdetails konnten nicht geladen werden.',
response.status,
)
}
const payload: unknown = await response.json()
const user = normalizeAdminUser(payload)
if (!user) {
throw new AuthRequestError('Antwortformat von /auth/user/{id} ist ungültig.', response.status)
}
return user
}
export interface UpdateAdminUserPayload {
userName?: string
isActive?: boolean
mustChangePassword?: boolean
}
export async function updateAdminUser(
userId: string,
payload: UpdateAdminUserPayload,
): Promise<AdminUser> {
const normalizedId = userId.trim()
if (normalizedId.length === 0) {
throw new AuthRequestError('Ungültige Benutzer-ID.', 400)
}
let response: Response
try {
response = await fetch(`/auth/user/${encodeURIComponent(normalizedId)}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
})
} catch (error) {
throw toAdminUserError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401) {
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', response.status)
}
if (response.status === 403) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Du bist angemeldet, aber nicht als Admin autorisiert.',
response.status,
)
}
if (response.status === 404) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(apiMessage ?? 'Benutzer wurde nicht gefunden.', response.status)
}
if (response.status === 409) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(apiMessage ?? 'Benutzername ist bereits vergeben.', response.status)
}
if (response.status === 400) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(apiMessage ?? 'Eingaben sind ungültig.', response.status)
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Benutzer konnte nicht aktualisiert werden.',
response.status,
)
}
const body: unknown = await response.json()
const user = normalizeAdminUser(body)
if (!user) {
throw new AuthRequestError('Antwortformat von /auth/user/{id} ist ungültig.', response.status)
}
return user
}
export interface CreateAdminUserPayload {
userName: string
startPassword: string
isAdmin: boolean
isActive: boolean
}
export class CreateAdminUserError extends AuthRequestError {
readonly fieldErrors: string[]
constructor(message: string, status: number, fieldErrors: string[] = []) {
super(message, status)
this.name = 'CreateAdminUserError'
this.fieldErrors = fieldErrors
}
}
async function readApiPayload(response: Response): Promise<{ message: string | null; errors: string[] }> {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
return { message: null, errors: [] }
}
try {
const payload = (await response.json()) as { message?: unknown; errors?: unknown }
const message =
typeof payload.message === 'string' && payload.message.trim().length > 0
? payload.message.trim()
: null
const errors = Array.isArray(payload.errors)
? payload.errors
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: []
return { message, errors }
} catch {
return { message: null, errors: [] }
}
}
export async function createAdminUser(payload: CreateAdminUserPayload): Promise<AdminUser> {
let response: Response
try {
response = await fetch('/auth/user', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
})
} catch (error) {
throw toAdminUserError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401) {
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', response.status)
}
if (response.status === 403) {
const { message } = await readApiPayload(response)
throw new AuthRequestError(
message ?? FORBIDDEN_NOT_ADMIN_MESSAGE,
response.status,
)
}
if (response.status === 409) {
const { message } = await readApiPayload(response)
throw new CreateAdminUserError(
message ?? 'Benutzername ist bereits vergeben.',
response.status,
)
}
if (response.status === 422) {
const { message, errors } = await readApiPayload(response)
throw new CreateAdminUserError(
message ?? 'Passwort erfüllt nicht die Sicherheitsanforderungen.',
response.status,
errors,
)
}
if (response.status === 400) {
const { message, errors } = await readApiPayload(response)
throw new CreateAdminUserError(
message ?? 'Benutzer konnte nicht erstellt werden.',
response.status,
errors,
)
}
if (!response.ok) {
const { message } = await readApiPayload(response)
throw new AuthRequestError(
message ?? 'Benutzer konnte nicht erstellt werden.',
response.status,
)
}
const body: unknown = await response.json()
const user = normalizeAdminUser(body)
if (!user) {
throw new AuthRequestError('Antwortformat von POST /auth/user ist ungültig.', response.status)
}
return user
}
export const FORBIDDEN_NOT_ADMIN_MESSAGE = 'Du bist angemeldet, aber nicht als Admin autorisiert.'
+335
View File
@@ -0,0 +1,335 @@
export interface CurrentUser {
id: string
userName: string
roles: string[]
isActive: boolean
mustChangePassword: boolean
}
export const ROLE_ADMIN = 'admin'
interface ApiMessageResponse {
message?: unknown
errors?: unknown
}
interface LoginPayload {
userName: string
password: string
}
interface ChangePasswordPayload {
oldPassword: string
newPassword: string
newPasswordConfirm: string
}
interface ApiErrorPayload {
message: string | null
errors: string[]
}
export class AuthRequestError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'AuthRequestError'
this.status = status
}
}
let cachedUser: CurrentUser | null = null
let sessionResolved = false
let pendingSessionRequest: Promise<CurrentUser | null> | null = null
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function normalizeCurrentUser(value: unknown): CurrentUser | null {
if (!isObject(value)) {
return null
}
const { id, userName, roles, isActive, mustChangePassword } = value
if (typeof id !== 'string') {
return null
}
const normalizedUserName = typeof userName === 'string' ? userName : ''
const normalizedRoles = Array.isArray(roles)
? roles.filter((role): role is string => typeof role === 'string').map((role) => role.trim()).filter((role) => role.length > 0)
: []
return {
id,
userName: normalizedUserName,
roles: normalizedRoles,
isActive: Boolean(isActive),
mustChangePassword: Boolean(mustChangePassword),
}
}
export function hasRole(user: CurrentUser | null | undefined, role: string): boolean {
if (!user) {
return false
}
const normalizedRole = role.trim().toLowerCase()
if (normalizedRole.length === 0) {
return false
}
return user.roles.some((userRole) => userRole.toLowerCase() === normalizedRole)
}
function isUnauthenticatedResponse(response: Response) {
if (response.status === 401 || response.status === 403) {
return true
}
if (!response.redirected) {
return false
}
try {
const url = new URL(response.url)
return url.pathname === '/auth/login'
} catch {
return false
}
}
async function readApiMessage(response: Response): Promise<string | null> {
const payload = await readApiErrorPayload(response)
return payload.message
}
function normalizeApiErrors(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
async function readApiErrorPayload(response: Response): Promise<ApiErrorPayload> {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
return { message: null, errors: [] }
}
try {
const body = (await response.json()) as ApiMessageResponse
const message =
typeof body.message === 'string' && body.message.trim().length > 0
? body.message.trim()
: null
return {
message,
errors: normalizeApiErrors(body.errors),
}
} catch {
return { message: null, errors: [] }
}
}
function toAuthError(error: unknown, fallbackMessage: string): AuthRequestError {
if (error instanceof AuthRequestError) {
return error
}
return new AuthRequestError(fallbackMessage, 0)
}
export function clearAuthSession() {
cachedUser = null
sessionResolved = false
pendingSessionRequest = null
}
export async function fetchCurrentUser(options: { force?: boolean } = {}): Promise<CurrentUser | null> {
const shouldForce = options.force === true
if (!shouldForce && sessionResolved) {
return cachedUser
}
if (!shouldForce && pendingSessionRequest) {
return pendingSessionRequest
}
const request = (async () => {
let response: Response
try {
response = await fetch('/auth/me', {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} catch (error) {
throw toAuthError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (isUnauthenticatedResponse(response)) {
cachedUser = null
sessionResolved = true
return null
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Benutzerdaten konnten nicht geladen werden.',
response.status,
)
}
const rawPayload: unknown = await response.json()
const user = normalizeCurrentUser(rawPayload)
if (!user) {
throw new AuthRequestError('Antwortformat von /auth/me ist ungültig.', response.status)
}
cachedUser = user
sessionResolved = true
return user
})().finally(() => {
pendingSessionRequest = null
})
pendingSessionRequest = request
return request
}
export async function login(payload: LoginPayload): Promise<void> {
let response: Response
try {
response = await fetch('/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
userName: payload.userName,
password: payload.password,
}),
})
} catch (error) {
throw toAuthError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
if (response.status === 400) {
throw new AuthRequestError(
apiMessage ?? 'Bitte Benutzername und Passwort eingeben.',
response.status,
)
}
if (response.status === 401) {
throw new AuthRequestError(apiMessage ?? 'Ungültige Anmeldedaten.', response.status)
}
if (response.status === 403) {
throw new AuthRequestError('Dieses Konto ist nicht aktiv.', response.status)
}
throw new AuthRequestError(
apiMessage ?? 'Anmeldung fehlgeschlagen. Bitte versuche es erneut.',
response.status,
)
}
sessionResolved = false
await fetchCurrentUser({ force: true })
}
export async function logout(): Promise<void> {
let response: Response
try {
response = await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
Accept: 'application/json',
},
})
} catch (error) {
throw toAuthError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401 || response.status === 403) {
clearAuthSession()
return
}
if (!response.ok) {
const apiMessage = await readApiMessage(response)
throw new AuthRequestError(
apiMessage ?? 'Abmeldung fehlgeschlagen. Bitte versuche es erneut.',
response.status,
)
}
clearAuthSession()
}
export async function changePassword(payload: ChangePasswordPayload): Promise<string> {
let response: Response
try {
response = await fetch('/auth/password', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
})
} catch (error) {
throw toAuthError(error, 'Server ist nicht erreichbar. Bitte später erneut versuchen.')
}
if (response.status === 401 || response.status === 403) {
clearAuthSession()
throw new AuthRequestError('Session abgelaufen. Bitte melde dich erneut an.', response.status)
}
if (!response.ok) {
const apiError = await readApiErrorPayload(response)
if (response.status === 400) {
const fallback = 'Passwortänderung fehlgeschlagen. Bitte Eingaben prüfen.'
const details = apiError.errors.length > 0 ? ` ${apiError.errors.join(' ')}` : ''
throw new AuthRequestError((apiError.message ?? fallback) + details, response.status)
}
throw new AuthRequestError(
apiError.message ?? 'Passwortänderung fehlgeschlagen. Bitte versuche es erneut.',
response.status,
)
}
const successMessage = await readApiMessage(response)
clearAuthSession()
return successMessage ?? 'Passwort erfolgreich geändert.'
}
+109
View File
@@ -0,0 +1,109 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export type AppBannerType = 'success' | 'info' | 'warning' | 'error'
export interface AppBannerMessage {
id: string
type: AppBannerType
title?: string
message: string
createdAt: number
}
interface PushBannerInput {
type?: AppBannerType
title?: string
message: string
}
const MAX_BANNERS = 8
const AUTO_DISMISS_MS = 6000
function createBannerId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `banner-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
export const useAppBannersStore = defineStore('app-banners', () => {
const banners = ref<AppBannerMessage[]>([])
const dismissTimers = new Map<string, ReturnType<typeof setTimeout>>()
function scheduleAutoDismiss(id: string) {
if (typeof window === 'undefined') {
return
}
const timer = setTimeout(() => {
dismiss(id)
}, AUTO_DISMISS_MS)
dismissTimers.set(id, timer)
}
function clearTimer(id: string) {
const timer = dismissTimers.get(id)
if (timer) {
clearTimeout(timer)
dismissTimers.delete(id)
}
}
function push(input: PushBannerInput) {
const now = Date.now()
const type = input.type ?? 'info'
const normalizedMessage = input.message.trim()
if (normalizedMessage.length === 0) {
return ''
}
const lastBanner = banners.value[banners.value.length - 1]
if (
lastBanner &&
lastBanner.type === type &&
lastBanner.message === normalizedMessage &&
now - lastBanner.createdAt < 1200
) {
return lastBanner.id
}
const banner: AppBannerMessage = {
id: createBannerId(),
type,
title: input.title,
message: normalizedMessage,
createdAt: now,
}
const next = [...banners.value, banner]
const removed = next.length > MAX_BANNERS ? next.slice(0, next.length - MAX_BANNERS) : []
removed.forEach((entry) => clearTimer(entry.id))
banners.value = next.slice(-MAX_BANNERS)
scheduleAutoDismiss(banner.id)
return banner.id
}
function pushError(message: string, title = 'Fehler') {
return push({ type: 'error', title, message })
}
function dismiss(id: string) {
clearTimer(id)
banners.value = banners.value.filter((banner) => banner.id !== id)
}
function clear() {
dismissTimers.forEach((timer) => clearTimeout(timer))
dismissTimers.clear()
banners.value = []
}
return {
banners,
push,
pushError,
dismiss,
clear,
}
})
-12
View File
@@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
+75 -37
View File
@@ -1,75 +1,113 @@
/* Shared layout primitives for route-level page shells */ /* =============================================================================
.hoard-page { Hoard Page-Layout-Primitives
============================================================================= */
.ui-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--hoard-page-gap, var(--space-6)); gap: var(--ui-page-gap, var(--space-6));
margin-inline: auto; margin-inline: auto;
width: min(100%, var(--hoard-page-width, 1120px)); width: min(100%, var(--ui-page-width, 1180px));
padding-block: padding-block:
var(--hoard-page-padding-start, var(--space-4)) var(--ui-page-padding-start, var(--space-5))
var(--hoard-page-padding-end, var(--space-8)); var(--ui-page-padding-end, var(--space-12));
} }
.hoard-page--centered { .ui-page--centered {
width: 100%; width: 100%;
margin-inline: 0; margin-inline: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: calc(100vh - var(--hoard-centered-offset, 210px)); min-height: calc(100vh - var(--ui-centered-offset, 220px));
padding: var(--hoard-centered-padding, var(--space-8) var(--space-4)); padding: var(--ui-centered-padding, var(--space-10) var(--space-4));
} }
.hoard-shell-grid { .ui-shell-grid {
display: grid; display: grid;
gap: var(--hoard-shell-gap, var(--space-8)); gap: var(--ui-shell-gap, var(--space-8));
width: min(100%, var(--hoard-shell-width, 1040px)); width: min(100%, var(--ui-shell-width, 1080px));
padding: var(--hoard-shell-padding, var(--space-8)); padding: var(--ui-shell-padding, var(--space-8));
}
.ui-page-header {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-width: 78ch;
}
.ui-page-header > h1 {
margin: 0;
font-size: var(--font-size-3xl);
letter-spacing: -0.02em;
}
.ui-page-header > p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-lg);
line-height: var(--line-height-loose);
}
.ui-page-header__meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-3);
} }
@media (width <= 960px) { @media (width <= 960px) {
.hoard-page { .ui-page {
width: 100%; width: 100%;
gap: var(--hoard-page-gap-mobile, var(--space-5)); gap: var(--ui-page-gap-mobile, var(--space-5));
padding-inline: padding-inline:
var(--hoard-page-padding-inline-start-mobile, max(var(--space-2), env(safe-area-inset-left))) var(--ui-page-padding-inline-start-mobile, max(var(--space-3), env(safe-area-inset-left)))
var(--hoard-page-padding-inline-end-mobile, max(var(--space-2), env(safe-area-inset-right))); var(--ui-page-padding-inline-end-mobile, max(var(--space-3), env(safe-area-inset-right)));
padding-block: padding-block:
var(--hoard-page-padding-start-mobile, var(--space-2)) var(--ui-page-padding-start-mobile, var(--space-3))
var(--hoard-page-padding-end-mobile, var(--space-6)); var(--ui-page-padding-end-mobile, var(--space-8));
} }
.hoard-page--centered { .ui-page--centered {
width: 100%; width: 100%;
min-height: calc(100vh - var(--hoard-centered-offset-mobile, 180px)); min-height: calc(100vh - var(--ui-centered-offset-mobile, 200px));
padding: var(--hoard-centered-padding-mobile, var(--space-5) var(--space-2)); padding: var(--ui-centered-padding-mobile, var(--space-6) var(--space-3));
} }
.hoard-shell-grid { .ui-shell-grid {
width: 100%; width: 100%;
gap: var(--hoard-shell-gap-mobile, var(--space-5)); gap: var(--ui-shell-gap-mobile, var(--space-5));
padding: padding:
var(--hoard-shell-padding-block-mobile, var(--space-5)) var(--ui-shell-padding-block-mobile, var(--space-6))
var(--hoard-shell-padding-inline-mobile, var(--space-4)); var(--ui-shell-padding-inline-mobile, var(--space-5));
}
.ui-page-header > h1 {
font-size: var(--font-size-2xl);
}
.ui-page-header > p {
font-size: var(--font-size-md);
} }
} }
@media (width <= 600px) { @media (width <= 600px) {
.hoard-page { .ui-page {
gap: var(--hoard-page-gap-mobile-xs, var(--space-4)); gap: var(--ui-page-gap-mobile-xs, var(--space-4));
padding-block: padding-block:
var(--hoard-page-padding-start-mobile-xs, var(--space-2)) var(--ui-page-padding-start-mobile-xs, var(--space-2))
var(--hoard-page-padding-end-mobile-xs, var(--space-5)); var(--ui-page-padding-end-mobile-xs, var(--space-6));
} }
.hoard-page--centered { .ui-page--centered {
min-height: calc(100vh - var(--hoard-centered-offset-mobile-xs, 164px)); min-height: calc(100vh - var(--ui-centered-offset-mobile-xs, 180px));
padding: var(--hoard-centered-padding-mobile-xs, var(--space-4) var(--space-2)); padding: var(--ui-centered-padding-mobile-xs, var(--space-5) var(--space-3));
} }
.hoard-shell-grid { .ui-shell-grid {
gap: var(--hoard-shell-gap-mobile-xs, var(--space-4)); gap: var(--ui-shell-gap-mobile-xs, var(--space-4));
padding: padding:
var(--hoard-shell-padding-block-mobile-xs, var(--space-4)) var(--ui-shell-padding-block-mobile-xs, var(--space-5))
var(--hoard-shell-padding-inline-mobile-xs, var(--space-3)); var(--ui-shell-padding-inline-mobile-xs, var(--space-4));
} }
} }
+200 -23
View File
@@ -1,55 +1,232 @@
/* Shared surface and content patterns */ /* =============================================================================
.hoard-kicker { Hoard Surface- und Inhaltspattern
margin: 0 0 var(--space-2); ============================================================================= */
.ui-kicker {
display: inline-flex;
align-items: center;
gap: var(--space-2);
margin: 0 0 var(--space-3);
color: var(--color-primary-700); color: var(--color-primary-700);
font-size: var(--font-size-sm); font-size: var(--font-size-xs);
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
} }
.hoard-kicker--wide { .ui-kicker::before {
letter-spacing: 0.06em; content: '';
width: 18px;
height: 1px;
background-color: currentcolor;
opacity: 0.6;
} }
.hoard-kicker--xs { .ui-kicker--wide {
font-size: var(--font-size-xs); letter-spacing: 0.12em;
letter-spacing: 0.04em;
} }
.hoard-action-row { .ui-kicker--xs {
margin-bottom: var(--space-2);
font-size: var(--font-size-2xs);
letter-spacing: 0.1em;
}
.ui-kicker--plain::before {
display: none;
}
.ui-action-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: var(--space-3); gap: var(--space-3);
} }
.hoard-panel-gradient { .ui-action-row--end {
background: linear-gradient( justify-content: flex-end;
var(--hoard-gradient-angle, 120deg), }
var(--hoard-gradient-start, color-mix(in srgb, var(--color-primary-100) 34%, var(--color-surface) 66%))
0%, .ui-panel-gradient {
var(--hoard-gradient-end, var(--color-surface)) var(--hoard-gradient-end-stop, 52%) background:
); radial-gradient(
120% 80% at 100% 0%,
color-mix(in srgb, var(--color-accent-lime) 14%, transparent) 0%,
transparent 60%
),
linear-gradient(
var(--ui-gradient-angle, 130deg),
var(--ui-gradient-start, color-mix(in srgb, var(--color-primary-100) 65%, var(--color-surface) 35%))
0%,
var(--ui-gradient-end, var(--color-surface)) var(--ui-gradient-end-stop, 60%)
);
}
.ui-spotlight {
position: relative;
overflow: hidden;
isolation: isolate;
}
.ui-spotlight::before,
.ui-spotlight::after {
content: '';
position: absolute;
z-index: -1;
pointer-events: none;
border-radius: var(--radius-full);
filter: blur(60px);
}
.ui-spotlight::before {
inset: -10% auto auto -10%;
width: 360px;
height: 360px;
background: color-mix(in srgb, var(--color-primary-300) 40%, transparent);
opacity: 0.55;
}
.ui-spotlight::after {
inset: auto -10% -10% auto;
width: 320px;
height: 320px;
background: color-mix(in srgb, var(--color-accent-lime) 35%, transparent);
opacity: 0.35;
}
[data-theme='dark'] .ui-spotlight::before {
background: color-mix(in srgb, var(--color-primary-700) 50%, transparent);
opacity: 0.45;
}
[data-theme='dark'] .ui-spotlight::after {
background: color-mix(in srgb, var(--color-primary-600) 30%, transparent);
opacity: 0.4;
}
.ui-chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 5px var(--space-3);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-full);
background-color: color-mix(in srgb, var(--color-surface) 88%, var(--color-surface-alt) 12%);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 500;
letter-spacing: 0.01em;
white-space: nowrap;
}
.ui-chip--brand {
border-color: color-mix(in srgb, var(--color-primary-300) 60%, var(--color-border) 40%);
background-color: color-mix(in srgb, var(--color-primary-100) 70%, var(--color-surface) 30%);
color: var(--color-primary-700);
}
.ui-chip--ghost {
border-color: var(--color-border-subtle);
background-color: transparent;
}
.ui-chip > .v-icon {
width: 14px;
height: 14px;
}
.ui-icon-tile {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: var(--radius-md);
color: var(--color-primary-700);
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--color-primary-100) 90%, var(--color-surface) 10%),
color-mix(in srgb, var(--color-primary-100) 60%, var(--color-surface) 40%)
);
border: 1px solid color-mix(in srgb, var(--color-primary-300) 35%, var(--color-border) 65%);
flex: 0 0 auto;
}
.ui-icon-tile--lg {
width: 48px;
height: 48px;
border-radius: var(--radius-lg);
}
.ui-icon-tile--ghost {
color: var(--color-text-secondary);
background: var(--color-surface);
border-color: var(--color-border);
}
.ui-divider-soft {
border: 0;
height: 1px;
background:
linear-gradient(
90deg,
transparent,
var(--color-border-subtle) 12%,
var(--color-border) 50%,
var(--color-border-subtle) 88%,
transparent
);
}
.ui-section-head {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-5);
max-width: 70ch;
}
.ui-section-head > h2 {
margin: 0;
font-size: var(--font-size-2xl);
letter-spacing: -0.015em;
}
.ui-section-head > p {
margin: 0;
color: var(--color-text-secondary);
} }
@media (width <= 960px) { @media (width <= 960px) {
.hoard-action-row { .ui-action-row {
gap: var(--space-2); gap: var(--space-2);
} }
.ui-spotlight::before,
.ui-spotlight::after {
width: 240px;
height: 240px;
filter: blur(48px);
}
} }
@media (width <= 600px) { @media (width <= 600px) {
.hoard-kicker { .ui-kicker {
margin-bottom: var(--space-1); margin-bottom: var(--space-2);
} }
.hoard-action-row { .ui-action-row {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
width: 100%; width: 100%;
} }
.hoard-action-row > * { .ui-action-row > * {
width: 100%; width: 100%;
} }
.ui-section-head > h2 {
font-size: var(--font-size-xl);
}
} }
+270 -372
View File
@@ -1,460 +1,358 @@
# Hoard Style Guide # Hoard Style Guide
## Zielbild ## Zielbild
Hoard soll wirken wie eine ruhige, moderne Dateiverwaltung im Browser: klar, aufgeräumt, produktiv und leicht verständlich. Nicht verspielt, nicht luxuriös, nicht wie ein komplexes Notion-Klon-System. Die Oberfläche soll in erster Linie Ordnung vermitteln und den Fokus auf Dateien, Ordner, Vorschau und Markdown-Bearbeitung legen. Hoard soll wirken wie eine **moderne, produktive Dateiverwaltung im Browser** ruhig, klar und mit hoher visueller Sorgfalt. Die Grundphilosophie bleibt dateiorientiert: Inhalte, Dateinamen, Pfade und Aktionen stehen im Vordergrund, nicht UI-Effekte. Gleichzeitig darf sich Hoard hochwertig und präzise anfühlen „polished" statt „fancy", näher an einer modernen Linear-/Vercel-/Notion-Sidebar als an einer alten Admin-UI.
Die Gestaltung orientiert sich an drei Prinzipien: Die Gestaltung folgt drei Prinzipien:
1. **Dateien zuerst** Inhalte, Dateinamen, Pfade und Aktionen stehen optisch im Vordergrund. 1. **Dateien zuerst** Inhalte, Namen, Strukturen und Aktionen sind optisch dominant, nicht die Chrome.
2. **Ruhige Oberfläche** wenig visuelle Unruhe, viel Weißraum, zurückhaltende Farben. 2. **Ruhe mit Charakter** viel Weißraum, klare Flächen, ausgewählte Akzente. Wenig Elemente, aber jedes mit Sorgfalt.
3. **Grün als Identität, nicht als Dauerfeuer** die Markenfarbe wird gezielt für Auswahl, Primäraktionen und Status genutzt, nicht flächendeckend. 3. **Grün als Identität, nicht als Dauerfeuer** die Markenfarbe wird gezielt für Auswahl, Primäraktionen, Status und Branding eingesetzt nicht flächendeckend.
## Stilrichtung ## Modernisierungs-Direktive
Die Seite soll sich optisch zwischen Google Drive und einer modernen self-hosted Admin-Oberfläche bewegen. Beim Redesign gilt zusätzlich:
**So soll es wirken:** - **Hochwertige App-Shell:** Sidebar und Topbar dürfen sich „premium" anfühlen (klare Hierarchie, animierter Active-Indicator, gradient-tinged Brand-Bereich).
- sachlich und sauber - **Layered Surfaces:** Statt einer einzigen Surface-Farbe gibt es eine kleine Surface-Hierarchie (`surface`, `surface-alt`, `surface-elevated`) mit subtil gestaffelten Schatten.
- freundlich, aber nicht verspielt - **Microinteractions:** kurze, präzise Hover-/Active-/Focus-Animationen (160240 ms). Keine wabernden, dauerhaften Bewegungen.
- modern, aber bewusst einfach - **Ambient Gradients:** Hero-/Brand-Bereiche dürfen sehr weiche, breit gestreute Verläufe in Markenfarbe haben. Arbeitsflächen bleiben funktional und ruhig.
- produktiv statt marketing-lastig - **Typografische Präzision:** klare Größen-Hierarchie, ruhige Headlines, viel `letter-spacing` Disziplin, keine dekorativen Schriften.
- leicht technisch, ohne kalt zu sein - **Konsistente Iconographie:** ausschließlich MDI Outline-Icons mit gleicher Größe und Farbverhalten.
- **Light- und Dark-Mode gleichwertig:** alle neuen Stile müssen in beiden Modi funktional und ästhetisch tragen, ausschließlich über Tokens (`color-mix`, CSS-Variablen) gelöst.
**So soll es nicht wirken:** **Was weiterhin tabu ist:**
- kein Neon- oder Gaming-Look - kein Glassmorphism (echte Backdrop-Blur-Karten),
- kein Glassmorphism - kein Neon-/Gaming-Look,
- keine harten Kontraste überall - keine harten, durchgängigen Kontraste,
- keine überladenen Kartenlayouts - keine Mehrfarbigkeit aus dem Akzent-Setup,
- keine bunte Mischung vieler Akzentfarben - keine dauerhaften Animationen oder Parallax-Spielereien.
## Visuelle Identität ## Visuelle Identität
Die Markenwirkung basiert auf neutralen Flächen mit einem kontrollierten Grün als Wiedererkennungsmerkmal. Das Grün kommt aus dem Logo und steht für Ablage, Struktur, Ruhe und „self-hosted tool statt „Social App. Markenwirkung basiert auf neutralen, leicht warmen Flächen mit kontrolliertem Grün. Das Grün stammt aus dem Logo (Stapel-/Ordner-Idee) und steht für Ablage, Struktur und „self-hosted tool" statt „Social App". Akzent-Lime nur als gezielter Akzent-Glow im Branding und in Marketing-Sektionen.
Die App soll **light-first** gestaltet werden. Ein Dark Mode kann später kommen, aber das Grunddesign wird zuerst für helle Oberflächen optimiert. Das spart Aufwand und hält die UI konsistenter. Die App ist **light-first**, Dark-Mode ist gleichwertig zu pflegen. Neue Komponenten beziehen Farben aus Design-Tokens.
## Farbpalette ## Farbpalette
### Primärfarben ### Primärfarben (Light)
- **Primary 700:** `#1C652F` - **Primary 800:** `#10421E` tiefster Markenton, Texte auf Light-Surface.
Für Primärbuttons, aktive Icons, Fokusrahmen, Links in aktiven Zuständen. - **Primary 700:** `#1C652F` Primärbuttons, aktive Icons, Fokusrahmen.
- **Primary 600:** `#2E7D32` - **Primary 600:** `#2E7D32` Hover-Zustände, aktive Navigation.
Für Hover-Zustände und aktive Navigation. - **Primary 500:** `#3C8F42` ausgewählte Einträge, Badges, bestätigende States.
- **Primary 500:** `#3C8F42` - **Primary 300:** `#A8D5A2` weiche Hintergründe für Auswahlflächen.
Für ausgewählte Einträge, Badges, bestätigende States. - **Primary 100:** `#EAF5E8` sehr subtile Hervorhebungen, Tints.
- **Primary 300:** `#A8D5A2` - **Primary 050:** `#F4FAF1` fast unsichtbare Pflasterfläche für Hero-Glows.
Für weiche Hintergründe von Auswahlflächen.
- **Primary 100:** `#EAF5E8`
Für sehr subtile Hervorhebungen.
### Akzentfarbe ### Akzent
- **Accent Lime:** `#B7E36B` - **Accent Lime:** `#B7E36B` sparsam: Logo-Glow, kleine Highlights, Upload-Fortschritt.
Nur sehr sparsam einsetzen, z. B. kleiner Glow im Logo-Bereich, leichtere Highlights, Upload-Fortschritt oder ausgewählte Illustrationsdetails. Nicht für normalen Text.
### Neutrale Farben ### Neutrale (Light)
- **Background:** `#F6F8F5` - **Background:** `#F5F8F2`
Hauptseitenhintergrund. - **Background Tint:** `#EEF3EA` ambient Hintergrund-Verlauf.
- **Surface 1:** `#FFFFFF` - **Surface:** `#FFFFFF` Karten, Panels, Dialoge.
Karten, Panels, Modals, Dialoge. - **Surface Alt:** `#F1F4EE` Toolbar, Tabellenkopf, Sekundärflächen.
- **Surface 2:** `#F1F4EF` - **Surface Elevated:** `#FBFCF8` höhere Layer (Drawer-Inhalt, Dropdowns).
Sekundäre Flächen, Toolbar-Hintergründe, Tabellenkopf. - **Border:** `#DCE3D6`
- **Border:** `#DCE4D8` - **Border Strong:** `#C5CFBE`
Standard-Border. - **Border Subtle:** `#E8EDE2` innere Trennlinien.
- **Border Strong:** `#C7D2C2`
Stärkere Abgrenzung bei Panels und Inputs.
### Textfarben ### Text (Light)
- **Text Primary:** `#1F2A21` - **Text Primary:** `#1A2A1E`
- **Text Secondary:** `#5F6E62` - **Text Secondary:** `#5A6A5E`
- **Text Muted:** `#7D8A80` - **Text Muted:** `#7B897F`
- **Text On Primary:** `#FFFFFF` - **Text On Primary:** `#FFFFFF`
### Statusfarben ### Dark Mode
Schlicht halten, nicht zu bunt. Dark-Mode wird vollständig über Tokens gespiegelt. Eckwerte:
- Background: `#0E1115`
- Surface: `#161A20`
- Surface Alt: `#1B2028`
- Surface Elevated: `#1F252E`
- Border: `#2A323D`
- Border Strong: `#3A4452`
- Text Primary: `#E9EFF3`
- Text Secondary: `#B6BEC8`
- Primary 700 (dark): `#5FB968`
- Primary 600 (dark): `#4EA758`
- Primary 100 (dark): `#1F2922`
### Status
- **Success:** `#2E7D32` - **Success:** `#2E7D32`
- **Warning:** `#B7791F` - **Warning:** `#B7791F`
- **Danger:** `#C0392B` - **Danger:** `#C0392B`
- **Info:** `#2F6FB3` - **Info:** `#2F6FB3`
## Typografie ## Typografie
Die Typografie soll neutral, gut lesbar und unauffällig modern sein. Keine dekorativen Schriften. Neutral, gut lesbar, unauffällig modern keine dekorativen Schriften.
**Empfohlene Schriftfamilie:** **Stack:** `Inter, "Segoe UI", Roboto, system-ui, sans-serif`
- `Inter`
- Fallback: `system-ui, sans-serif`
**Typografische Regeln:** **Skala (rem-orientiert, fixe px-Breakpoints, keine viewport-Skalierung):**
- normale Lesetexte: 1415 px - `--font-size-2xs`: 11px Status-Labels, Mini-Meta.
- UI-Haupttext in Listen und Tabellen: 14 px - `--font-size-xs`: 12px Tabellenmeta, Helper-Text.
- Seitenüberschriften: 2428 px - `--font-size-sm`: 13px sekundärer UI-Text.
- Bereichsüberschriften: 1820 px - `--font-size-md`: 14px Standard-UI-Text, Listen.
- kleine Meta-Infos: 1213 px - `--font-size-lg`: 16px akzentuierte UI-Hervorhebung.
- Zeilenhöhe großzügig halten, besonders in Dateilisten und Formularen - `--font-size-xl`: 20px Bereichsüberschriften.
- `--font-size-2xl`: 26px Seitenüberschriften.
- `--font-size-3xl`: 32px Display-Headlines (Hero).
- `--font-size-display`: 44px sehr seltene Marketing-Display-Texte.
**Schriftgewicht:** **Gewichte:**
- 400 für normalen Fließtext - 400 für Fließtext.
- 500 für UI-Text und Labels - 500 für UI-Text und Labels.
- 600 für Titel und aktive Elemente - 600 für Titel, Kicker, aktive Items.
- 700 nur sehr gezielt - 700 nur sehr gezielt (Display-Headlines, Logo-Wortmarke).
## Layoutprinzip **Letter-Spacing-Disziplin:**
Das Layout soll stark an eine Dateiverwaltung erinnern. - Display-Headlines: leicht negatives Tracking (`-0.01em` bis `-0.02em`).
- Kicker/Labels (uppercase): `0.05em` bis `0.08em`.
- Standard: `0`.
### Grundaufbau ## Spacing
- **Topbar** für Logo, Breadcrumbs, Kontextaktionen, Benutzer-Menü Modulare Skala in 4er-Schritten:
- **linke Sidebar** für Navigation
- **Hauptbereich** für Dateiliste oder Grid
- **rechte Vorschau / Detailansicht** optional als Panel oder getrennte Ansicht
### Seitenbreite und Abstand ```
- großzügige horizontale Abstände --space-1: 4px
- Hauptinhalte nicht zu schmal machen --space-2: 8px
- Panels mit genug Luft, aber ohne Dashboard-Overdesign --space-3: 12px
- Standard-Abstandssystem in 4er- oder 8er-Schritten --space-4: 16px
--space-5: 20px
--space-6: 24px
--space-7: 28px
--space-8: 32px
--space-10: 40px
--space-12: 48px
--space-16: 64px
```
**Spacing-Skala:** Hauptseiten: `--ui-page-width` 11201200px, Padding orientiert sich an `--space-6`/`--space-8`.
- 4 px
- 8 px
- 12 px
- 16 px
- 20 px
- 24 px
- 32 px
- 40 px
## Formensprache ## Formensprache (Border-Radien)
Die Formensprache soll weich, aber nicht rundgelutscht sein. - `--radius-xs`: 6px kleine Pills, Status-Badges.
- `--radius-sm`: 10px Buttons, kleine Controls.
- `--radius-md`: 14px Inputs, Dropdowns, Listzeilen.
- `--radius-lg`: 18px Cards, Panels.
- `--radius-xl`: 22px Hero-Shells, Modals.
- `--radius-full`: 999px nur für Avatar/Tag-Pills.
- kleine Controls: `8px` Radius Keine pillenförmigen Vollflächen als Grundstil.
- Panels, Inputs, Dropdowns: `10px`
- Modals und größere Karten: `14px`
- keine pillenförmigen Vollflächen als Grundstil
## Schatten und Tiefe ## Schatten & Tiefe
Sehr zurückhaltend einsetzen. Die App soll stabil und ruhig wirken, nicht schwebend. Mehrstufiges, sehr ruhiges Schatten-System:
**Standard-Schatten:** ```
- Panels: leichter, weicher Schatten --shadow-xs: 0 1px 1px rgba(20, 30, 22, 0.04);
- Dropdowns / Modals: etwas stärker, aber nie dramatisch --shadow-sm: 0 2px 6px rgba(20, 30, 22, 0.06);
- keine starken farbigen Schatten im Produktivbereich --shadow-md: 0 8px 22px rgba(20, 30, 22, 0.08);
- grüner Glow nur höchstens im Branding oder auf Marketing-/Login-Flächen --shadow-lg: 0 18px 44px rgba(20, 30, 22, 0.12);
--shadow-glow: 0 12px 36px rgba(28, 101, 47, 0.18);
```
## Komponentenstil Regeln:
- Panels: `--shadow-sm`.
- Hover-Lift max. 2 px, nur bei klar interaktiven Elementen.
- Dropdowns/Modals: `--shadow-md` bis `--shadow-lg`.
- `--shadow-glow` ausschließlich für Brand-Bereiche (Login-Hero, Welcome-Hero).
- Keine farbigen Schatten in Arbeitsflächen.
## Motion
- Standard-Easing: `cubic-bezier(0.2, 0, 0, 1)`.
- `--transition-fast`: 160 ms (Hover, Buttons, Listzeilen).
- `--transition-medium`: 220 ms (Route-Wechsel, Banner).
- `--transition-slow`: 320 ms (Hero-Enter, Drawer).
- Globale Page-Enter-Animation: weicher Y-Slide + Fade (≤ 8 px).
- `prefers-reduced-motion`: alle Transitions auf 1 ms reduzieren, keine Translates.
## App-Shell
### Topbar ### Topbar
Die Topbar ist ruhig und funktional. - Höhe ca. 64 px, opaker `surface`-Hintergrund, dünne Bottom-Border.
- Höhe ca. 6064 px - Branding links: Icon + Wortmarke + dezenter Subtext („Self-hosted Workspace").
- heller Hintergrund oder leicht abgesetzte Surface-Farbe - Page-Kontext (Name + Beschreibung) als sekundäre Info, nur ab `>1180px`.
- Logo links - Rechts: Theme-Toggle, Account-Menü oder Login-Button.
- Breadcrumbs klar lesbar, nicht zu klein - Subtile bottom shadow (`--shadow-xs`).
- Kontextaktionen rechts davon oder am rechten Rand
- dünne Unterkante oder subtile Shadow-Abgrenzung
### Sidebar ### Sidebar
Die Sidebar ist funktional, nicht dominant. - Breite 268284 px, Hintergrund `surface-alt`, leicht abgesetzt.
- feste Breite, ca. 240280 px - Active-Indicator: linker, animierter, vertikaler Strich (`--color-primary-600`) + grüne Tint-Fläche + dunklerer Text.
- leicht abgesetzte Hintergrundfläche - Hover: leichte Tint-Fläche, kein Scaling.
- aktive Einträge mit heller grüner Fläche und dunklerem Text - Sektionen mit Kicker-Labels (`Navigation`, `Admin`).
- Icons schlicht und einheitlich - Mindesthöhe pro Item: 44 px (Desktop), 48 px (Mobile).
- Navigation in logische Gruppen, aber ohne zu viele Sektionen - Mobile = Bottom-Sheet-Drawer mit abgerundeten Top-Ecken.
### Dateiliste ### Footer
Die Dateiliste ist das Herzstück der App. - Sehr ruhig, kompakt, `surface` mit dünner oberer Border.
- Links nur auf rechtlich/strukturell wichtige Routen (Impressum etc.).
- Mobile: in Grid mit Tap-Größen ≥ 44 px.
**Darstellung:** ## Komponenten
- standardmäßig Listenansicht
- klare Spalten für Name, Typ, Größe, geändert am
- Zeilenhöhe eher luftig statt kompakt gepresst
- Hover nur leicht hervorheben
- ausgewählte Zeile mit heller grüner Tönung
- Doppelklick oder klarer Primärklick zum Öffnen
- Dateisymbole farblich dezent
**Wichtig:** ### Cards / Panels
Die Liste soll strukturierter und ruhiger wirken als ein typisches Admin-Grid. Nicht wie eine Datenbanktabelle, sondern wie eine echte Dateiverwaltung. - Hintergrund: `surface` mit subtilem 180°-Verlauf zu `surface-alt`.
- Border: `--color-border`.
### Karten / Panels - Radius: `--radius-lg`.
Karten nur dort einsetzen, wo es fachlich Sinn ergibt: - Padding: `--space-6` (Standard), kleinere Inhalte `--space-4`.
- Vorschau-Panel - Hover (nur klar interaktive Cards): Border in Primary-300-Mix, `--shadow-md`, 2 px Y-Lift.
- Datei-Infos
- Upload-Status
- Modals
Nicht jede Seite künstlich in zehn Karten aufteilen.
### Buttons ### Buttons
Buttons sollen schlicht und klar sein. - **Primary:** grüner Hintergrund (`--color-primary-700`), weiße Schrift, `--shadow-xs`. Hover: `--color-primary-600` + leichter Glow. Active: ohne Lift.
- **Outlined:** transparenter Hintergrund, `--color-border-strong`, dunkler Text. Hover: leichte Primary-Tint-Fläche, Border zu Primary-Mix.
**Primärbutton:** - **Text/Tertiary:** für Zeilenaktionen, Toolbar, Nav. Hover: Surface-Tint.
- grüner Hintergrund - **Icon-Buttons:** mind. 40×40 (Desktop), 44×44 (Mobile).
- weiße Schrift - Letter-Spacing 0, kein Uppercase.
- mittlere Höhe - Pro Bereich genau **eine Primäraktion**.
- klar erkennbare Hover- und Disabled-Zustände
**Sekundärbutton:**
- helle Fläche mit Border
- dunkler Text
- kein zu aggressiver Kontrast
**Tertiärbutton / Icon-Button:**
- für Zeilenaktionen, Toolbar, Preview-Aktionen
- Hover mit leichter Surface-Abhebung
**Regel:**
Es sollte pro Bereich meist genau eine klare Primäraktion geben.
### Inputs ### Inputs
- weiße Fläche - Outline-Variante, `surface` Hintergrund.
- klarer Border - Border `--color-border-strong`, Focus-Ring 3 px Primary-300-Tint + Primary-600-Border.
- Fokuszustand mit grünem Ring oder grün betonter Border - Labels oberhalb (Vuetify-Outlined-Label), keine reinen Placeholder.
- keine dunklen, schweren Inputs - Mind. 44 px Höhe auf Mobile.
- Labels oberhalb statt Placeholder-only
### Modals und Dialoge ### Listen / Tabellen
- kompakt und funktional - Tabelle: `surface` Hintergrund, abgesetzter Tabellen-Header (`surface-alt`).
- deutlicher Titel - Hover: sehr subtile Primary-Tint-Tönung.
- klare Primär- und Sekundäraktion - Selected Row: Primary-100 Hintergrund, `--color-primary-700` Text.
- nicht zu breit - Border-Bottom in `--color-border-subtle`.
- Löschen-Aktionen visuell bewusst neutral mit Danger-Akzent nur am Button - `ui-list-row` (Datei-/Item-Zeilen): luftiges Padding, klare Spalten, `transform: translateX(2px)` beim Hover.
### Dropdowns und Kontextmenüs ### Status-Pills (`ui-status`)
- schlicht, hell, sauber getrennte Einträge - Kompakt, `--radius-xs`, mit dezentem Text/Background-Tint pro Status (Success/Info/Warning/Danger/Neutral).
- Icons optional, aber konsistent - Optional kleines Punktindikator-Dot vor dem Text.
- Hover klar sichtbar
- kritische Aktionen unten gruppieren
## Vorschau-Bereich ### Banner-Stack
Der Vorschau-Bereich ist ein zentraler Teil von Hoard und soll hochwertig, aber ruhig wirken. - Position: fixiert unten rechts (Desktop), unten zentriert (Mobile).
- Stapelbar, einzeln dismissable.
- Opake Hintergründe, `--shadow-md`, sauber lesbar auf jedem Inhalt.
- Enter/Leave: 180 ms Fade + 10 px Y.
### PDF-Vorschau ## Hero-/Marketing-Bereiche
- heller neutraler Hintergrund - Erlauben einen **ambient Verlauf** (`ui-panel-gradient` + Variablen).
- PDF sitzt auf einer weißen „Papier“-Fläche - Optional dezenter „Spotlight"-Glow (radialer Verlauf, `--color-primary-300` mit niedriger Opazität, blur).
- genug Rand um die Seite herum - Inhaltsbreite max ~64ch.
- Controls minimal und funktional - Display-Headlines mit `--font-size-3xl` oder `--font-size-display` (nur Hero).
- Hero-Tags (`ui-chip`/`ui-tag`) als kompakte, dezent gerahmte Pills.
### Bildvorschau ## Vorschau-Bereich (Files)
- dunklerer neutraler Viewer-Hintergrund ist okay, wenn das Bild dadurch besser wirkt - **PDF-Vorschau:** neutraler Hintergrund, weiße „Papier"-Fläche mit `--shadow-md`, genug Rand. Controls minimal.
- umgebende UI trotzdem konsistent mit dem restlichen Produkt halten - **Bildvorschau:** dunklerer neutraler Viewer-Hintergrund nur, wenn das Bild dadurch besser wirkt; UI-Chrome konsistent.
- keine übertriebene Galerie-Optik - **Markdown-Editor/Reader:** wirkt wie ein Arbeitsdokument, nicht wie Blogpost. Lesbreite ~70ch, klare Heading-Hierarchie, sehr ruhige Codeblöcke.
### Markdown-Ansicht / Editor ## Empty-/Loading-States
Markdown-Dateien sollen wie Arbeitsdokumente wirken, nicht wie Blogposts. - Empty: kleines Outline-Icon in Primary-Tint, kurzer Titel, ein Satz Begründung, eine klare CTA.
- Loading: Skeletons (graue Pulslinien) bevorzugt, ansonsten ein kleiner zentraler Spinner mit beschreibendem Text. Keine flackernden Spinner-Felder.
**Vorgaben:**
- gute Textbreite
- klare Hierarchie bei Überschriften
- dezente Codeblock-Gestaltung
- sehr gute Lesbarkeit
- Editor und Preview optisch zur restlichen App passend, auch wenn `md-editor-v3` eigene Defaults mitbringt
## Tabellen- und Listenverhalten
Da der Kern deiner App Listen, Dateiansichten und Metadaten sind, muss das Verhalten konsistent sein.
- Hover ist immer subtil
- Auswahl ist immer über denselben Grünton markiert
- aktive Navigation und aktive Listelemente nutzen dieselbe semantische Farbe
- Sortierung, falls später vorhanden, visuell zurückhaltend markieren
- Bulk-Actions nur anzeigen, wenn wirklich etwas ausgewählt ist
## Icons
Empfohlen ist ein schlanker, moderner Icon-Stil mit einheitlicher Konturstärke.
Geeignet wären z. B.:
- Lucide
- Heroicons
**Regeln:**
- möglichst Outline-Icons
- nur wenige gefüllte Icons
- Dateityp-Icons dürfen leicht differenziert sein, aber nicht bunt explodieren
- Ordner-Icon in gedecktem Grün oder neutralem Grau
## Status und Feedback
Feedback soll klar sein, aber nicht laut.
### Toasts
- kurze Texte
- kein unnötiger Fließtext
- success, error, info klar unterscheidbar
- am besten oben rechts oder unten rechts, aber konsistent
### Ladezustände
- Skeletons oder sehr schlichte Loader
- lieber ruhige Platzhalter statt hektische Spinner überall
### Leere Zustände
Leere Zustände sollen freundlich, aber nüchtern sein.
- kleines Icon oder einfache Illustration
- ein klarer Satz
- eine eindeutige Folgeaktion
## Login-Seite
Die Login-Seite darf minimal etwas mehr Branding zeigen als die Haupt-App.
**Empfehlung:**
- zentrierte Login-Card
- Logo sichtbar
- neutraler Hintergrund mit sehr leichter grüner Stimmung
- keine starke Hero-Sektion nötig
- Fokus auf schnellem Einstieg
## Responsive Verhalten ## Responsive Verhalten
Desktop ist der Hauptfokus. Mobile muss funktionieren, aber nicht die Priorität des MVP sein. Desktop ist Hauptfokus, Mobile muss aber sauber funktionieren.
### Desktop ### Breakpoints
- volle Sidebar - `@media (width <= 1180px)` Topbar-Kontextleiste verkürzen.
- großzügige Dateiliste - `@media (width <= 960px)` Sidebar wird Bottom-Drawer; Karten-/Grid-Bereiche werden einspaltig; Spacing wird reduziert.
- Preview neben Liste möglich - `@media (width <= 600px)` CTAs full-width; Schriften minimal kompakter; Footer als Grid; Status-Pills behalten Lesbarkeit.
### Tablet ### Pflicht
- Sidebar einklappbar - Desktop-Baseregeln nicht anfassen, nur Mobile-Overrides per Media Query.
- Preview eher als Overlay oder eigene Ansicht - Touch-Größen: Buttons ≥ 44 px, Icon-Buttons ≥ 44×44, Nav-Items ≥ 48 px.
- Safe-Areas via `env(safe-area-inset-*)` (Topbar-Padding, Footer/Drawer Bottom).
### Mobile - Banner-Stack respektiert Safe-Area Bottom.
- eher einfache Stapelansicht - Pflicht-Viewports im QA: `360x800`, `390x844`, `768x1024`, `1024x768`, `>=1280` jeweils Light + Dark.
- Fokus auf Navigation und Öffnen
- Bearbeitung von Markdown darf reduziert sein, solange Lesen und einfache Bedienung sauber funktionieren
### Umsetzungsstandard Responsivität (verbindlich)
Die folgenden Regeln bilden den aktuellen Responsive-Standard von Hoard und sollen bei allen kommenden UI-Aufgaben eingehalten werden.
1. **Desktop-first, Mobile-only Overrides**
- Desktop-Styles bleiben Basis.
- Mobile-Anpassungen ausschließlich in Media Queries, keine Änderungen an Desktop-Baseregeln.
2. **Breakpoints**
- `@media (width <= 960px)` für Tablet/Mobile-Umbruch (Layout-Stacks, Spacing-Reduktion, Drawer-/Footer-Verhalten).
- `@media (width <= 600px)` für Phone-Feinschliff (volle Breite für CTAs, kompaktere Typo/Abstände).
3. **Globale Responsive-Patterns zuerst nutzen**
- Wiederverwendbare Anpassungen immer zuerst in den globalen Dateien pflegen:
- `GUI/src/global.css`
- `GUI/src/styles/global/page-layouts.css`
- `GUI/src/styles/global/surface-patterns.css`
- Seiten-spezifisches `scoped` CSS nur für wirklich lokale Sonderfälle.
4. **Touch-Zielgrößen und Bedienbarkeit**
- Interaktive Elemente mobil mit klarer Daumen-Bedienbarkeit:
- Buttons mindestens `44px` Höhe.
- Icon-Buttons mindestens `44x44px`.
- Navigations-Listeneinträge mindestens `48px` Höhe.
- Aktionszeilen (`hoard-action-row`) auf kleinen Geräten vertikal stapeln, damit Primäraktionen gut erreichbar sind.
5. **Safe-Area-Unterstützung**
- Bei mobilen Außenabständen `env(safe-area-inset-*)` berücksichtigen (iOS/Android).
- Muster: `max(var(--space-x), env(safe-area-inset-...))` als Fallback-sicherer Abstand.
- Besonders relevant für App-Bar-Ränder, Seiten-Padding und Bottom-Bereiche (Drawer/Footer).
6. **App-Shell-Muster**
- Mobile Navigation bleibt als Bottom-Sheet-Drawer.
- Desktop-Navigation bleibt unverändert (keine visuelle Regression auf großen Viewports).
- Footer-Links auf kleinen Screens umbauen (Wrap/Grid), mit ausreichend großen Tap-Flächen.
7. **Seiten-Muster**
- Content-Änderungen vermeiden; nur Layout/Bedienung anpassen.
- Karten-/Grid-Bereiche bei `<= 960px` in Einspalten-Layout überführen.
- Primäre CTAs bei `<= 600px` in voller Breite anzeigen.
8. **Responsive QA vor Abschluss**
- Pflicht-Viewports: `360x800`, `390x844`, `768x1024`, `1024x768`, `>=1280`.
- Prüfen: Navigation, Scroll-Verhalten, CTA-Erreichbarkeit, Formular-Bedienbarkeit.
- Desktop-Regression-Check: bei `>=1024` darf sich das gewollte Desktop-Erscheinungsbild nicht ändern.
## Interaktionsprinzipien ## Interaktionsprinzipien
- Primäraktionen immer klar sichtbar - Primäraktionen klar sichtbar.
- destruktive Aktionen nie zu nah an Standardaktionen - Destruktives nie direkt neben Standardaktion.
- Dateizeilen sollen sich klickbar anfühlen, ohne wie Buttons auszusehen - Dateizeilen sind klickbar, dürfen aber nicht wie Buttons aussehen.
- Hover, Active und Selected Zustände deutlich unterscheiden - Hover, Active und Selected klar unterscheidbar (unterschiedliche Tints/Outlines).
- Fokuszustände für Tastaturbedienung sauber sichtbar machen - Fokus für Tastaturbedienung immer sichtbar (`outline: 2px solid var(--color-primary-500); outline-offset: 2px`).
## Stil für konkrete Bereiche
### Ordnernavigation
- Breadcrumbs schlicht, klickbar, gut lesbar
- aktueller Ordner klar markiert
- Pfad nie visuell dominanter als der Inhalt
### Upload-Bereich
- Uploads funktional anzeigen, nicht dramatisch
- Fortschritt mit ruhiger grüner Progressbar
- Fehlerfälle klar lesbar
- Upload-Liste eher kompakt halten
### Datei-Details
- Metadaten in sauberem Zwei-Spalten-Raster oder kompakter Liste
- Labels und Werte klar unterscheidbar
- Aktionen wie Download, Umbenennen, Löschen klar getrennt
## Designregeln für die Umsetzung ## Designregeln für die Umsetzung
### Immer tun ### Immer
- viel Weißraum lassen - Tokens nutzen (Farben, Spacing, Radius, Shadow).
- Grün nur gezielt einsetzen - Patterns wiederverwenden (`ui-panel`, `ui-page`, `ui-action-row`, `ui-kicker`, `ui-status`, `ui-chip`, `ui-spotlight`).
- Borders und Surface-Unterschiede subtil halten - Light- und Dark-Mode gleichwertig prüfen.
- Listen und Dateiansichten priorisieren - Animationen kurz halten und `prefers-reduced-motion` respektieren.
- Text gut lesbar und eher neutral halten
### Vermeiden ### Vermeiden
- zu viele Karten - zu viele Karten ohne Funktion,
- zu viele Farbflächen - zu viele Akzentfarben gleichzeitig,
- übertriebene Animationen - harte 1:1-Kontraste,
- mehrere konkurrierende Akzentfarben - echte Glasflächen / Backdrop-Blur,
- rein dekorative UI-Elemente ohne Nutzen - dauernde Bewegungen,
- Schriftgrößen, die mit der Viewport-Breite skalieren.
## Beispiel für Design Tokens ## Beispiel: Design-Tokens
Diese Tokens können später direkt in CSS-Variablen oder ein Theme übernommen werden.
```css ```css
:root { :root {
--color-bg: #F6F8F5; /* Surfaces */
--color-surface: #FFFFFF; --color-bg: #f5f8f2;
--color-surface-alt: #F1F4EF; --color-bg-tint: #eef3ea;
--color-border: #DCE4D8; --color-surface: #ffffff;
--color-border-strong: #C7D2C2; --color-surface-alt: #f1f4ee;
--color-surface-elevated: #fbfcf8;
--color-text: #1F2A21; --color-border: #dce3d6;
--color-text-secondary: #5F6E62; --color-border-strong: #c5cfbe;
--color-text-muted: #7D8A80; --color-border-subtle: #e8ede2;
--color-primary-700: #1C652F; /* Text */
--color-primary-600: #2E7D32; --color-text: #1a2a1e;
--color-primary-500: #3C8F42; --color-text-secondary: #5a6a5e;
--color-primary-300: #A8D5A2; --color-text-muted: #7b897f;
--color-primary-100: #EAF5E8; --color-text-on-primary: #ffffff;
--color-accent-lime: #B7E36B;
--color-success: #2E7D32; /* Brand */
--color-warning: #B7791F; --color-primary-800: #10421e;
--color-danger: #C0392B; --color-primary-700: #1c652f;
--color-info: #2F6FB3; --color-primary-600: #2e7d32;
--color-primary-500: #3c8f42;
--color-primary-300: #a8d5a2;
--color-primary-100: #eaf5e8;
--color-primary-050: #f4faf1;
--color-accent-lime: #b7e36b;
--radius-sm: 8px; /* Status */
--radius-md: 10px; --color-success: #2e7d32;
--radius-lg: 14px; --color-warning: #b7791f;
--color-danger: #c0392b;
--color-info: #2f6fb3;
--shadow-sm: 0 1px 2px rgba(16, 24, 18, 0.06); /* Radius */
--shadow-md: 0 6px 18px rgba(16, 24, 18, 0.08); --radius-xs: 6px;
--radius-sm: 10px;
--radius-md: 14px;
--radius-lg: 18px;
--radius-xl: 22px;
--radius-full: 999px;
/* Shadows */
--shadow-xs: 0 1px 1px rgba(20, 30, 22, 0.04);
--shadow-sm: 0 2px 6px rgba(20, 30, 22, 0.06);
--shadow-md: 0 8px 22px rgba(20, 30, 22, 0.08);
--shadow-lg: 0 18px 44px rgba(20, 30, 22, 0.12);
--shadow-glow: 0 12px 36px rgba(28, 101, 47, 0.18);
/* Spacing */
--space-1: 4px; --space-1: 4px;
--space-2: 8px; --space-2: 8px;
--space-3: 12px; --space-3: 12px;
--space-4: 16px; --space-4: 16px;
--space-5: 20px; --space-5: 20px;
--space-6: 24px; --space-6: 24px;
--space-7: 28px;
--space-8: 32px; --space-8: 32px;
--space-10: 40px; --space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Motion */
--transition-fast: 160ms cubic-bezier(0.2, 0, 0, 1);
--transition-medium: 220ms cubic-bezier(0.2, 0, 0, 1);
--transition-slow: 320ms cubic-bezier(0.2, 0, 0, 1);
} }
``` ```
## Abschlussentscheidung für Hoard
Für Hoard ist ein **ruhiger, light-first, dateiorientierter Produktivstil mit neutralen Flächen und kontrolliertem Grün als Markenfarbe** die passendste Richtung.
Das passt zur Produktidee, weil:
- die App primär eine Dateiverwaltung ist
- Markdown-Bearbeitung und Vorschau im Vordergrund stehen
- die Oberfläche einfach und wartbar bleiben soll
- der Stil gut allein umsetzbar ist
- die UI professionell wirkt, ohne nach großem SaaS-Produkt aussehen zu müssen
## Kurzfassung als Design-Leitlinie ## Kurzfassung als Design-Leitlinie
Wenn du bei einer UI-Entscheidung unsicher bist, gilt: Wenn du bei einer UI-Entscheidung unsicher bist:
**Lieber schlichter als spektakulär. Lieber Google-Drive-artig als Dashboard-artig. Lieber ruhige Flächen und klare Listen als visuelle Effekte. Grün ist Identität, nicht Dekoration.** **Lieber präzise als spektakulär. Lieber ruhige Flächen mit Charakter als visuelle Effekte. Grün ist Identität, nicht Dekoration. Jedes Element muss eine Funktion haben sonst fliegt es raus.**
-127
View File
@@ -1,127 +0,0 @@
# Projektübersicht self-hosted Datei- und Markdown-App
## Projektidee
Ich baue eine einfache self-hosted Web-App, die sich funktional zwischen Google Drive, Notion und Obsidian einordnet. Der Schwerpunkt liegt aber klar auf einer Google-Drive-artigen Oberfläche mit Dateien und Ordnern. Markdown-Dateien sollen direkt im Browser bearbeitet werden können, andere Dateien sollen gespeichert und wenn möglich als Vorschau angezeigt werden.
## Ziel des Projekts
Das Projekt ist ein kleines, bewusst einfach gehaltenes Solo-Projekt neben meiner Ausbildung. Es soll mehrere Benutzer unterstützen, aber technisch und funktional schlank bleiben. Wichtig ist ein realistisches MVP, das sauber läuft und später erweitert werden kann.
## Geplanter Tech-Stack
- Frontend: Vue 3
- Markdown-Editor: md-editor-v3
- Backend: ASP.NET Core mit C#
- Datenbank: PostgreSQL
- Dateispeicher: MinIO als S3-kompatibler Storage
- Authentifizierung: klassische Cookie-basierte Authentifizierung, keine JWTs
- Deployment: self-hosted Web-App auf meinem eigenen Server
## Kernfunktionen für das MVP
- Login mit bestehenden Accounts
- Kein öffentliches Registrieren
- Ein initialer Admin-Account wird zuerst erstellt
- Weitere Benutzer werden später nur manuell durch den Admin angelegt
- Dateien und Ordner anlegen, hochladen und öffnen
- Durch Ordnerstrukturen navigieren
- Google-Drive-artige Hauptansicht mit Dateiliste und Vorschau
- Markdown-Dateien direkt im Browser bearbeiten
- PDFs und Bilder als Vorschau anzeigen
- Andere Dateien einfach speichern und bei Bedarf herunterladen oder öffnen
## Was bewusst nicht Teil des MVP ist
- Keine Registrierung für normale Nutzer
- Kein Teilen oder Freigeben von Dateien
- Keine Suche
- Keine Versionierung oder Dateihistorie
- Keine Echtzeit-Zusammenarbeit
- Keine Desktop-App oder Mobile-App
- Keine komplizierte Rechteverwaltung
- Keine JWT-, OAuth- oder SSO-Lösung
## Gewünschter Stil der Anwendung
Die Oberfläche soll sich eher an Google Drive orientieren als an Notion oder Obsidian. Wichtig sind Übersicht, einfache Navigation und ein klarer Fokus auf Dateien, Ordner, Vorschau und Bearbeitung. Die App soll schlicht, pragmatisch und gut allein umsetzbar sein.
## Sprachregel für UI-Texte
- Umlaute sind ausdrücklich erwünscht (`ä`, `ö`, `ü`, `Ä`, `Ö`, `Ü`).
- Keine Umschreibungen mit `ae`, `oe`, `ue` in sichtbaren deutschen Texten.
## Wie die App später wirken soll
Die Anwendung soll wie eine einfache Dateiverwaltung im Browser wirken. Man meldet sich an, sieht seine Ordner und Dateien, kann sich durch die Struktur klicken, PDFs und Bilder direkt ansehen und Markdown-Dateien öffnen und bearbeiten. Der Fokus liegt auf Einfachheit statt auf vielen Sonderfunktionen.
## Technische Leitidee
Das Projekt soll möglichst einfach aufgebaut werden. Dateimetadaten liegen in PostgreSQL, die eigentlichen Dateien in MinIO. Das Backend verwaltet Login, Benutzer, Ordner, Dateien und Vorschau-Informationen. Das Frontend bildet hauptsächlich die Dateiverwaltung, Vorschau und Markdown-Bearbeitung ab. Die gesamte Architektur soll bewusst schlank bleiben, damit sie für ein Solo-Projekt realistisch ist.
## Projektbeschreibung für eine KI
Ich baue alleine neben meiner Ausbildung eine einfache self-hosted Web-App für mehrere Benutzer. Die App kombiniert eine Google-Drive-artige Dateiverwaltung mit einfacher Markdown-Bearbeitung. Benutzer sollen durch Ordner und Dateien navigieren können, Bilder und PDFs in einer Vorschau sehen und Markdown-Dateien direkt im Browser bearbeiten. Es gibt keine öffentliche Registrierung, kein Teilen, keine Suche und keine Versionierung. Benutzerkonten werden manuell angelegt, beginnend mit einem initialen Admin-Account. Der Tech-Stack besteht aus Vue 3 im Frontend, md-editor-v3 als Markdown-Editor, ASP.NET Core mit C# im Backend, PostgreSQL für Metadaten, MinIO als S3-kompatiblen Dateispeicher und Cookie-basierter Authentifizierung.
## Frontend-Designquelle (Style Guide)
- Es gibt einen zentralen Design-Guide unter `GUI/style.md`.
- Dieser Guide definiert die visuelle Richtung für Hoard: light-first, dateiorientiert, ruhige Flächen, gezielte Verwendung von Grün als Markenfarbe.
- Enthalten sind Farbpalette, Typografie, Spacing, Border-Radien, Schatten, Komponentenregeln und Interaktionsprinzipien.
## Angelegte globale CSS-Basis
- Statt `app.css` wurde eine zentrale globale Datei `GUI/src/global.css` angelegt und verwendet.
- Diese Datei wird in `GUI/src/main.ts` über `import './global.css'` eingebunden.
- Zusätzlich wurden modulare globale CSS-Dateien angelegt: `GUI/src/styles/global/page-layouts.css` und `GUI/src/styles/global/surface-patterns.css`.
- Beide Module werden ebenfalls zentral in `GUI/src/main.ts` eingebunden und bündeln wiederkehrende Layout-/Surface-Patterns.
- Inhaltlich stellt `global.css` bereit:
- Design-Tokens als CSS-Variablen (`:root`) für Farben, Spacing, Radius, Schatten, Typografie und Statusfarben.
- Globale Basisstile für `html`, `body`, Links, Überschriften, Fokuszustände und Scrollbars.
- Vuetify-nahe globale Anpassungen für App-Shell und Standardkomponenten (Topbar, Sidebar, Cards, Buttons, Inputs, Tabellen).
- Wiederverwendbare Utility-/Pattern-Klassen für Hoard-Seiten, z. B. `hoard-panel`, `hoard-toolbar`, `hoard-list-row`, `hoard-empty-state`, `hoard-status`.
- Responsive Verhalten für kleinere Viewports per Media Query.
## Anleitung: CSS-Patterns verwenden
- Neue Seiten standardmäßig mit `hoard-page` aufbauen; für zentrierte Vollhöhen-Ansichten zusätzlich `hoard-page--centered`.
- Karten-/Shell-Container als `hoard-shell-grid hoard-panel` verwenden; Breite/Abstände pro Seite über CSS-Variablen setzen (`--hoard-shell-width`, `--hoard-shell-gap`, `--hoard-shell-padding`).
- Wiederkehrende Headlines/Kicker mit `hoard-kicker` nutzen, Varianten bei Bedarf mit `hoard-kicker--wide` oder `hoard-kicker--xs`.
- Button-/Link-Aktionszeilen mit `hoard-action-row` bauen statt pro Seite eigene Flex-Definitionen zu duplizieren.
- Gradient-Flächen über `hoard-panel-gradient` + Variablen steuern (`--hoard-gradient-angle`, `--hoard-gradient-start`, `--hoard-gradient-end`, `--hoard-gradient-end-stop`), nicht pro Seite komplett neu definieren.
- Lokales `scoped` CSS nur für wirklich seitenspezifische Styles verwenden; alles Wiederverwendbare zuerst in `GUI/src/styles/global/page-layouts.css` oder `GUI/src/styles/global/surface-patterns.css` ergänzen.
## Aktueller Stand
- `GUI/src/Layout.vue` bildet die zentrale App-Shell mit Topbar, Sidebar, Footer, Routen-Kontext und responsivem Drawer-Verhalten.
- Darkmode (`light`/`dark`) ist global integriert (Toggle in der Topbar, Persistenz in `localStorage`, Theme-Tokens in CSS/Vuetify).
- Öffentliche Kernseiten sind im einheitlichen Hoard-Stil umgesetzt: `Home.vue` (Landingpage), `Login.vue`, `404NotFound.vue`, `Impressum.vue`.
- Das Topbar-Branding nutzt das App-Icon aus `GUI/src/assets/images/icon.svg`.
- Globale CSS-Struktur ist aktiv: `GUI/src/global.css` (Tokens/Basis) sowie `GUI/src/styles/global/page-layouts.css` und `GUI/src/styles/global/surface-patterns.css` für wiederverwendbare Patterns.
- Sidebar-Sichtbarkeit unterstützt `Visibility.Route` mit optionalem `visibilityRoute` in `GUI/src/plugins/routesLayout.ts`.
- Mobile-Touch-Optimierung ist für alle aktuellen öffentlichen Oberflächen aktiv (Shell, Home, Login, Impressum, 404), inklusive Safe-Area-Unterstützung.
- Desktop-Ansicht bleibt unverändert, da alle neuen Anpassungen ausschließlich in mobilen Breakpoints (`<= 960px`, Feinschliff `<= 600px`) umgesetzt sind.
- Backend-API ist auf ein Minimal-Setup reduziert und stellt aktuell den Test-Endpunkt `GET /api/health` bereit.
- Swagger/OpenAPI ist im Backend nur im Development-Modus aktiv (`/swagger`).
- Frontend-Build (`npm run build` im `GUI`-Projekt) schreibt direkt nach `API/wwwroot`; das Backend liefert die SPA und statische Assets aus.
- Backend nutzt jetzt PostgreSQL über `ConnectionStrings:Postgres` mit EF Core (`ApplicationDbContext`) und führt Migrationen beim Start automatisch aus.
- Temporäre Test-Entity und Test-CRUD-Endpunkt (`api/test-items`) wurden wieder entfernt; aktuell bleibt der minimale Health-Endpunkt `GET /api/health`.
- Für lokale Entwicklung liegt unter `API/Dev/docker-compose.yml` ein Stack mit PostgreSQL (`localhost:5432`) und pgAdmin (`localhost:5050`).
- API lädt optional `API/appsettings.custom.json`; wenn vorhanden, überschreibt sie Werte aus `appsettings.json`.
- `API/appsettings.custom.json` ist in `.gitignore` hinterlegt, damit lokale Konfigurationswerte nicht versehentlich committed werden.
## Änderungen durch Codex
- Grundlegender UI-Neuaufbau der App-Shell (`GUI/src/Layout.vue`) inklusive Navigation, Footer und Seitenkontext.
- Einführung eines globalen Theme-Managements (`light`/`dark`) über `GUI/src/plugins/vuetify.ts`, `GUI/src/global.css` und `localStorage`.
- Überarbeitung der zentralen öffentlichen Seiten (`Home.vue`, `Login.vue`, `404NotFound.vue`, `Impressum.vue`) auf ein einheitliches Hoard-Design.
- Erweiterung von `GUI/src/plugins/routesLayout.ts` um routeabhängige Sidebar-Sichtbarkeit (`Visibility.Route`, `visibilityRoute`).
- Konsolidierung der UI-Texte auf deutsche Umlaute gemäß Sprachregel.
- Aufbau und fortlaufende Konsolidierung der globalen CSS-Basis (`global.css`) inkl. Fokus-/Auswahl-Polish.
- CSS-Debloat-Refactor: gemeinsame Oberflächen-Patterns in `GUI/src/styles/global/page-layouts.css` und `GUI/src/styles/global/surface-patterns.css` ausgelagert und zentral in `GUI/src/main.ts` eingebunden.
- `codexInfo.md` um eine kompakte Nutzunganleitung für die globalen CSS-Patterns ergänzt.
- Mobile-Usability über globale Styles erweitert: größere Touch-Ziele (`v-btn`, Navigationspunkte), Safe-Area-Paddings und mobile Spacing-Feinschliff in `GUI/src/global.css` sowie den globalen Pattern-Dateien.
- `GUI/src/Layout.vue` für Mobile optimiert: entzerrte App-Bar-Abstände, touchfreundlicher Bottom-Sheet-Drawer und besser bedienbarer Footer auf kleinen Viewports.
- Mobile-spezifische Detailoptimierungen in `Home.vue`, `Login.vue`, `Impressum.vue` und `404NotFound.vue` ergänzt (Actions, Card-/Form-Spacing, CTA-Stacking), ohne Desktop-Basislayout zu verändern.
- `GUI/style.md` um einen verbindlichen Abschnitt „Umsetzungsstandard Responsivität“ ergänzt (Breakpoints, Touch-Zielgrößen, Safe-Area, globale Pattern-Nutzung, QA-Checkliste), damit Folgeaufgaben denselben Stil beibehalten.
- Topbar-Kontext in `GUI/src/Layout.vue` für schmalere Breiten beruhigt: auf Mobile wird der Seitenkontext komplett ausgeblendet, auf mittleren Breiten bleibt nur der Seitentitel (ohne Unterzeile), damit das Header-Layout sauber und nicht gequetscht wirkt.
- Backend-Template-Code bereinigt: `WeatherForecastController` und `WeatherForecast` entfernt, OpenAPI-Templatepaket aus `API/API.csproj` entfernt.
- Neuen Test-Controller `API/Controllers/HealthController.cs` angelegt (`GET /api/health`), der `200 OK` zurückgibt.
- `GUI/vite.config.ts` Build-Ausgabe auf `API/wwwroot` umgestellt (`outDir`) und Bereinigung des Zielordners beim Build aktiviert (`emptyOutDir: true`).
- `API/Program.cs` erweitert, damit statische Dateien aus `wwwroot` inkl. SPA-Fallback (`index.html`) ausgeliefert werden.
- SPA-Fallback im Backend aufgeteilt: Frontend-Routen liefern `index.html`, unbekannte `/api/*`-Routen bleiben korrekt `404` statt auf die SPA zu fallen.
- Swagger im Backend ergänzt: `Swashbuckle.AspNetCore` eingebunden, Services registriert und UI nur in `Development` aktiviert.
- PostgreSQL-Integration im Backend umgesetzt: `Npgsql.EntityFrameworkCore.PostgreSQL` + `Microsoft.EntityFrameworkCore.Design` in `API/API.csproj` ergänzt und Connection String `ConnectionStrings:Postgres` in den Settings hinterlegt.
- `API/Database/ApplicationDbContext.cs` mit Test-Entity `API/Models/Test/TestItem.cs` angelegt; erste Migrationen in `API/Migrations` erstellt.
- `API/Program.cs` um `AddDbContext` (Postgres) und `Database.Migrate()` beim Start erweitert, damit Migrationen automatisch angewendet werden.
- `API/Controllers/TestItemsController.cs` als einfacher CRUD-Testcontroller (`GET/POST/PUT/DELETE`) unter `api/test-items` ergänzt.
- Dev-Stack für lokale Datenbankarbeit ergänzt: `API/Dev/docker-compose.yml` startet PostgreSQL + pgAdmin.
- `API/Program.cs` so erweitert, dass optional `appsettings.custom.json` geladen wird und bei vorhandener Datei bevorzugte lokale Overrides möglich sind.
- Neue lokale Konfigurationsdatei `API/appsettings.custom.json` aus der bisherigen `appsettings.json` angelegt und in `.gitignore` ergänzt.
- Test-Datentyp und Test-API wieder entfernt: `API/Models/Test/TestItem.cs` und `API/Controllers/TestItemsController.cs` gelöscht, `ApplicationDbContext` bereinigt.
- Neue Migration `RemoveTestItems` erstellt (`API/Migrations/20260418153650_RemoveTestItems.cs`), die Tabelle `test_items` entfernt und den Snapshot aktualisiert.