Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c37a9185b | |||
| 178bc8731e | |||
| 1d00fb3a4b | |||
| 847ac119d8 | |||
| b29d174141 | |||
| 7e2ca4c9e2 | |||
| a512aaa0a7 | |||
| 529ea77d13 | |||
| 2c882fce4a | |||
| 3c780e292b | |||
| 6740038e9a | |||
| 10bf4b94ad | |||
| 3f826546ea | |||
| 14176a3ee2 | |||
| b2984fcf1a | |||
| bd261b6868 | |||
| f830fe4967 | |||
| 6c2a149f96 | |||
| 86ed227566 | |||
| 38ec3741ab | |||
| fc99c91bd8 | |||
| ffaf5d24c1 | |||
| fcd2dca8dc | |||
| db8ed2a868 | |||
| 522d31dc6e | |||
| 36ba210323 | |||
| 8ccc515a7b | |||
| d8ae756948 | |||
| f3b34df5bd | |||
| 3b910850cb | |||
| b9101a4582 | |||
| 58744e46b6 |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ nunit-*.xml
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
dist-ssr/
|
dist-ssr/
|
||||||
|
API/wwwroot/
|
||||||
.vite/
|
.vite/
|
||||||
.npm/
|
.npm/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
@@ -84,6 +85,8 @@ __screenshots__/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
*.local
|
*.local
|
||||||
|
API/appsettings.custom.json
|
||||||
|
API/appsettings.custom.*.json
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
~$*
|
~$*
|
||||||
|
|||||||
+7
-1
@@ -7,7 +7,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.6" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/health")]
|
||||||
|
public class HealthController : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult Get()
|
||||||
|
{
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace API.Controllers
|
|
||||||
{
|
|
||||||
[ApiController]
|
|
||||||
[Route("[controller]")]
|
|
||||||
public class WeatherForecastController : ControllerBase
|
|
||||||
{
|
|
||||||
private static readonly string[] Summaries =
|
|
||||||
[
|
|
||||||
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
|
|
||||||
];
|
|
||||||
|
|
||||||
[HttpGet(Name = "GetWeatherForecast")]
|
|
||||||
public IEnumerable<WeatherForecast> Get()
|
|
||||||
{
|
|
||||||
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
|
||||||
{
|
|
||||||
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
|
||||||
TemperatureC = Random.Shared.Next(-20, 55),
|
|
||||||
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
|
|
||||||
})
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using API.Models;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Database;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: hoard-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: hoard
|
||||||
|
POSTGRES_USER: hoard
|
||||||
|
POSTGRES_PASSWORD: hoard_dev_password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- hoard_postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U hoard -d hoard"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4:9.3
|
||||||
|
container_name: hoard-pgadmin
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_EMAIL: familiehimmelberg@gmail.com
|
||||||
|
PGADMIN_DEFAULT_PASSWORD: admin
|
||||||
|
PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||||
|
ports:
|
||||||
|
- "5050:80"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- hoard_pgadmin_data:/var/lib/pgadmin
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
hoard_postgres_data:
|
||||||
|
hoard_pgadmin_data:
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// <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("20260418134949_InitialPostgres")]
|
||||||
|
partial class InitialPostgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Models.Test.TestItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("test_items", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialPostgres : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "test_items",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||||
|
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_test_items", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "test_items");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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("20260418153650_RemoveTestItems")]
|
||||||
|
partial class RemoveTestItems
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RemoveTestItems : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "test_items");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "test_items",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
CreatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||||
|
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
UpdatedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_test_items", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+291
@@ -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'
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using API.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-8
@@ -1,23 +1,154 @@
|
|||||||
var builder = WebApplication.CreateBuilder(args);
|
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;
|
||||||
|
|
||||||
// Add services to the container.
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
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")
|
||||||
|
?? throw new InvalidOperationException("Connection string 'Postgres' wurde nicht gefunden.");
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseNpgsql(connectionString);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddOpenApi();
|
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();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
using (var scope = app.Services.CreateScope())
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
var startupLogger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
startupLogger.LogInformation("Starte Datenbankmigrationen.");
|
||||||
|
dbContext.Database.Migrate();
|
||||||
|
var seedService = scope.ServiceProvider.GetRequiredService<IdentitySeedService>();
|
||||||
|
await seedService.SeedAsync();
|
||||||
|
startupLogger.LogInformation("Backend-Initialisierung abgeschlossen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
var webRootPath = app.Environment.WebRootPath ?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||||
|
var indexFilePath = Path.Combine(webRootPath, "index.html");
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseDefaultFiles();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseHttpLogging();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapFallback(async context =>
|
||||||
|
{
|
||||||
|
if (context.Request.Path.StartsWithSegments("/api"))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(indexFilePath))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Response.ContentType = "text/html; charset=utf-8";
|
||||||
|
await context.Response.SendFileAsync(indexFilePath);
|
||||||
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
static bool IsApiRequest(HttpRequest request)
|
||||||
|
{
|
||||||
|
return request.Path.StartsWithSegments("/api")
|
||||||
|
|| request.Path.StartsWithSegments("/auth");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace API.Security
|
||||||
|
{
|
||||||
|
public static class PolicyNames
|
||||||
|
{
|
||||||
|
public const string AdminOnly = "admin-only";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace API.Security
|
||||||
|
{
|
||||||
|
public static class RoleNames
|
||||||
|
{
|
||||||
|
public const string Admin = "admin";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
namespace API
|
|
||||||
{
|
|
||||||
public class WeatherForecast
|
|
||||||
{
|
|
||||||
public DateOnly Date { get; set; }
|
|
||||||
|
|
||||||
public int TemperatureC { get; set; }
|
|
||||||
|
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
||||||
|
|
||||||
public string? Summary { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Postgres": "Host=localhost;Port=5432;Database=hoard;Username=hoard;Password=hoard_dev_password"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Postgres": ""
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -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 (160–280 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
@@ -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>
|
||||||
|
|||||||
Generated
+47
-8
@@ -8,9 +8,12 @@
|
|||||||
"name": "-",
|
"name": "-",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/roboto": "^5.2.10",
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.31",
|
"vue": "^3.5.31",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4",
|
||||||
|
"vuetify": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
@@ -59,7 +62,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -515,10 +517,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource/roboto": {
|
||||||
|
"version": "5.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz",
|
||||||
|
"integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -564,6 +576,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mdi/font": {
|
||||||
|
"version": "7.4.47",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
|
||||||
|
"integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
|
||||||
@@ -886,7 +904,6 @@
|
|||||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1342,7 +1359,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -2304,7 +2320,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^7.7.7"
|
"@vue/devtools-api": "^7.7.7"
|
||||||
},
|
},
|
||||||
@@ -2616,7 +2631,6 @@
|
|||||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -2705,7 +2719,6 @@
|
|||||||
"integrity": "sha512-baBr4jUVSLJ0RPyZ2nK0zS2+W8hNHbM4hEzfvllukmRPVS3xDG5ATTNtbRXrKIOE2b8/FsPWJAOnuIxcs7g3cw==",
|
"integrity": "sha512-baBr4jUVSLJ0RPyZ2nK0zS2+W8hNHbM4hEzfvllukmRPVS3xDG5ATTNtbRXrKIOE2b8/FsPWJAOnuIxcs7g3cw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@@ -2921,7 +2934,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
||||||
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.32",
|
"@vue/compiler-dom": "3.5.32",
|
||||||
"@vue/compiler-sfc": "3.5.32",
|
"@vue/compiler-sfc": "3.5.32",
|
||||||
@@ -3033,6 +3045,33 @@
|
|||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vuetify": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-pFysKOHuY3dROTVh9PdlhVz50ZR0E5/goY5ecTXc8F8tajUA2ee3xZ8Lqs1WtEw/X3w93wx/LogyjgaQCAL/Ig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/johnleider"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.7",
|
||||||
|
"vite-plugin-vuetify": ">=2.1.0",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"webpack-plugin-vuetify": ">=3.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite-plugin-vuetify": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"webpack-plugin-vuetify": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webpack-virtual-modules": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
|
|||||||
+4
-1
@@ -12,9 +12,12 @@
|
|||||||
"format": "prettier --write --experimental-cli src/"
|
"format": "prettier --write --experimental-cli src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/roboto": "^5.2.10",
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.31",
|
"vue": "^3.5.31",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4",
|
||||||
|
"vuetify": "^4.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 34 KiB |
@@ -1,11 +0,0 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<h1>You did it!</h1>
|
|
||||||
<p>
|
|
||||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
|
||||||
documentation
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
+1079
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,74 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048" width="2048" height="2048">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="borderGrad" x1="560" y1="520" x2="1500" y2="1455" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#061117"/>
|
||||||
|
<stop offset="0.24" stop-color="#0a2530"/>
|
||||||
|
<stop offset="0.48" stop-color="#0a3946"/>
|
||||||
|
<stop offset="0.74" stop-color="#081f28"/>
|
||||||
|
<stop offset="1" stop-color="#050e13"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="borderGlow" cx="1210" cy="930" r="720" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#1ea97e" stop-opacity="0.06"/>
|
||||||
|
<stop offset="0.45" stop-color="#0d4e4d" stop-opacity="0.05"/>
|
||||||
|
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<linearGradient id="topGrad" x1="590" y1="720" x2="1355" y2="540" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#9bdb61"/>
|
||||||
|
<stop offset="0.56" stop-color="#b9ee76"/>
|
||||||
|
<stop offset="1" stop-color="#a6df67"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="leftGrad" x1="588" y1="670" x2="726" y2="1305" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#0a8b4d"/>
|
||||||
|
<stop offset="0.52" stop-color="#067841"/>
|
||||||
|
<stop offset="1" stop-color="#005a33"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="frontGrad" x1="1120" y1="745" x2="1030" y2="1385" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#2dbf47"/>
|
||||||
|
<stop offset="0.42" stop-color="#24a847"/>
|
||||||
|
<stop offset="1" stop-color="#006b3b"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="frontHighlight" cx="1110" cy="850" r="500" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#d6ff9c" stop-opacity="0.035"/>
|
||||||
|
<stop offset="0.6" stop-color="#7ad26f" stop-opacity="0.01"/>
|
||||||
|
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<linearGradient id="stripeGrad" x1="740" y1="560" x2="1335" y2="695" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#0b7d42"/>
|
||||||
|
<stop offset="1" stop-color="#165f37"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="panelShadowGrad" x1="950" y1="1110" x2="1310" y2="1295" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#0a7a41" stop-opacity="0.45"/>
|
||||||
|
<stop offset="0.45" stop-color="#086a39" stop-opacity="0.58"/>
|
||||||
|
<stop offset="1" stop-color="#044f2c" stop-opacity="0.70"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="borderRingClip" clipPathUnits="userSpaceOnUse">
|
||||||
|
<path d="M 888.0 1477.5 L 867.0 1477.5 L 865.0 1475.5 L 854.0 1473.5 L 812.0 1451.5 L 811.0 1449.5 L 798.0 1443.5 L 720.0 1397.5 L 644.0 1347.5 L 576.0 1299.5 L 563.0 1289.5 L 545.5 1269.0 L 541.5 1257.0 L 540.5 1245.0 L 541.5 669.0 L 545.5 658.0 L 559.0 641.5 L 572.0 633.5 L 589.0 629.5 L 591.0 627.5 L 604.0 625.5 L 606.0 623.5 L 619.0 621.5 L 646.0 613.5 L 652.0 613.5 L 654.0 611.5 L 676.0 607.5 L 678.0 605.5 L 684.0 605.5 L 694.0 601.5 L 700.0 601.5 L 751.0 587.5 L 757.0 587.5 L 759.0 585.5 L 765.0 585.5 L 767.0 583.5 L 773.0 583.5 L 783.0 579.5 L 789.0 579.5 L 791.0 577.5 L 805.0 575.5 L 807.0 573.5 L 813.0 573.5 L 880.0 555.5 L 886.0 555.5 L 888.0 553.5 L 894.0 553.5 L 962.0 535.5 L 968.0 535.5 L 970.0 533.5 L 984.0 531.5 L 987.0 529.5 L 1000.0 527.5 L 1002.0 525.5 L 1008.0 525.5 L 1010.0 523.5 L 1032.0 519.5 L 1034.0 517.5 L 1040.0 517.5 L 1042.0 515.5 L 1064.0 511.5 L 1066.0 509.5 L 1072.0 509.5 L 1090.0 503.5 L 1096.0 503.5 L 1098.0 501.5 L 1104.0 501.5 L 1107.0 499.5 L 1120.0 497.5 L 1122.0 495.5 L 1136.0 493.5 L 1138.0 491.5 L 1144.0 491.5 L 1147.0 489.5 L 1166.0 489.5 L 1175.0 491.5 L 1222.0 513.5 L 1227.0 517.5 L 1245.0 525.5 L 1246.0 527.5 L 1253.0 529.5 L 1273.0 541.5 L 1287.0 547.5 L 1288.0 549.5 L 1291.0 549.5 L 1292.0 551.5 L 1329.0 569.5 L 1330.0 571.5 L 1441.0 629.5 L 1482.0 655.5 L 1497.5 675.0 L 1503.5 692.0 L 1503.5 1264.0 L 1495.5 1281.0 L 1486.0 1291.5 L 1475.0 1299.5 L 1455.0 1307.5 L 1437.0 1311.5 L 1435.0 1313.5 L 1405.0 1321.5 L 1403.0 1323.5 L 1398.0 1323.5 L 1396.0 1325.5 L 1365.0 1333.5 L 1363.0 1335.5 L 1351.0 1337.5 L 1330.0 1345.5 L 1318.0 1347.5 L 1303.0 1353.5 L 1278.0 1359.5 L 1263.0 1365.5 L 1258.0 1365.5 L 1243.0 1371.5 L 1225.0 1375.5 L 1203.0 1383.5 L 1198.0 1383.5 L 1196.0 1385.5 L 1172.0 1391.5 L 1170.0 1393.5 L 1165.0 1393.5 L 1143.0 1401.5 L 1138.0 1401.5 L 1130.0 1405.5 L 1118.0 1407.5 L 1116.0 1409.5 L 1046.0 1429.5 L 1031.0 1435.5 L 1006.0 1441.5 L 991.0 1447.5 L 986.0 1447.5 L 971.0 1453.5 L 966.0 1453.5 L 964.0 1455.5 L 939.0 1461.5 L 937.0 1463.5 L 932.0 1463.5 L 930.0 1465.5 Z M 892.5 1411.0 L 923.0 1403.5 L 925.0 1401.5 L 930.0 1401.5 L 932.0 1399.5 L 937.0 1399.5 L 939.0 1397.5 L 999.0 1381.5 L 1001.0 1379.5 L 1006.0 1379.5 L 1008.0 1377.5 L 1020.0 1375.5 L 1022.0 1373.5 L 1027.0 1373.5 L 1029.0 1371.5 L 1034.0 1371.5 L 1050.0 1365.5 L 1055.0 1365.5 L 1063.0 1361.5 L 1089.0 1355.5 L 1104.0 1349.5 L 1109.0 1349.5 L 1111.0 1347.5 L 1136.0 1341.5 L 1138.0 1339.5 L 1157.0 1335.5 L 1159.0 1333.5 L 1238.0 1311.5 L 1240.0 1309.5 L 1245.0 1309.5 L 1254.0 1305.5 L 1326.0 1285.5 L 1328.0 1283.5 L 1408.0 1261.5 L 1425.0 1255.5 L 1437.5 1245.0 L 1441.5 1239.0 L 1442.5 1232.0 L 1442.5 760.0 L 1441.5 706.0 L 1433.0 693.5 L 1422.0 689.5 L 1403.0 691.5 L 1388.0 695.5 L 1368.0 697.5 L 1365.0 699.5 L 1344.0 701.5 L 1342.0 703.5 L 1308.0 707.5 L 1294.0 711.5 L 1285.0 711.5 L 1282.0 713.5 L 1262.0 715.5 L 1248.0 719.5 L 1215.0 723.5 L 1212.0 725.5 L 1170.0 731.5 L 1156.0 735.5 L 1147.0 735.5 L 1144.0 737.5 L 1135.0 737.5 L 1132.0 739.5 L 1124.0 739.5 L 1098.0 745.5 L 1055.0 751.5 L 1041.0 755.5 L 1021.0 757.5 L 1007.0 761.5 L 1000.0 761.5 L 997.0 763.5 L 977.0 765.5 L 974.0 767.5 L 953.0 769.5 L 950.0 771.5 L 942.0 771.5 L 939.0 773.5 L 931.0 773.5 L 905.0 779.5 L 896.0 779.5 L 881.0 783.5 L 849.0 787.5 L 846.0 789.5 L 805.0 795.5 L 791.0 799.5 L 771.0 801.5 L 761.0 805.5 L 751.5 814.0 L 745.5 827.0 L 745.5 1333.0 L 786.0 1359.5 L 809.0 1371.5 L 861.0 1403.5 L 879.0 1411.5 Z" clip-rule="evenodd"/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="panelClip" clipPathUnits="userSpaceOnUse">
|
||||||
|
<path d="M 901.0 1371.5 L 893.0 1371.5 L 877.0 1365.5 L 808.0 1329.5 L 791.5 1318.0 L 791.5 847.0 L 793.5 842.0 L 803.0 833.5 L 818.0 829.5 L 827.0 829.5 L 841.0 825.5 L 850.0 825.5 L 853.0 823.5 L 874.0 821.5 L 877.0 819.5 L 909.0 815.5 L 912.0 813.5 L 932.0 811.5 L 946.0 807.5 L 955.0 807.5 L 969.0 803.5 L 977.0 803.5 L 980.0 801.5 L 1012.0 797.5 L 1015.0 795.5 L 1047.0 791.5 L 1050.0 789.5 L 1059.0 789.5 L 1073.0 785.5 L 1082.0 785.5 L 1085.0 783.5 L 1128.0 777.5 L 1131.0 775.5 L 1140.0 775.5 L 1143.0 773.5 L 1175.0 769.5 L 1189.0 765.5 L 1198.0 765.5 L 1201.0 763.5 L 1234.0 759.5 L 1237.0 757.5 L 1279.0 751.5 L 1282.0 749.5 L 1291.0 749.5 L 1294.0 747.5 L 1302.0 747.5 L 1352.0 737.5 L 1401.0 730.5 L 1401.5 1213.0 L 1397.5 1223.0 L 1389.0 1231.5 L 1373.0 1237.5 L 1368.0 1237.5 L 1366.0 1239.5 L 1361.0 1239.5 L 1359.0 1241.5 L 1333.0 1247.5 L 1324.0 1251.5 L 1319.0 1251.5 L 1317.0 1253.5 L 1312.0 1253.5 L 1310.0 1255.5 L 1285.0 1261.5 L 1283.0 1263.5 L 1277.0 1263.5 L 1275.0 1265.5 L 1256.0 1269.5 L 1241.0 1275.5 L 1236.0 1275.5 L 1187.0 1289.5 L 1185.0 1291.5 L 1165.0 1295.5 L 1163.0 1297.5 L 1158.0 1297.5 L 1149.0 1301.5 L 1144.0 1301.5 L 1142.0 1303.5 L 1109.0 1311.5 L 1107.0 1313.5 L 1088.0 1317.5 L 1086.0 1319.5 L 1067.0 1323.5 L 1058.0 1327.5 L 1052.0 1327.5 L 1050.0 1329.5 L 1015.0 1339.5 L 1010.0 1339.5 L 1008.0 1341.5 L 975.0 1349.5 L 973.0 1351.5 L 968.0 1351.5 L 959.0 1355.5 L 954.0 1355.5 L 952.0 1357.5 L 947.0 1357.5 Z M 906.5 1279.0 L 957.0 1265.5 L 979.0 1261.5 L 981.0 1259.5 L 986.0 1259.5 L 997.0 1255.5 L 1027.0 1249.5 L 1029.5 1247.0 L 1029.5 1100.0 L 1032.0 1097.5 L 1037.0 1097.5 L 1040.0 1095.5 L 1056.0 1093.5 L 1058.0 1091.5 L 1064.0 1091.5 L 1076.0 1087.5 L 1083.0 1087.5 L 1111.0 1079.5 L 1145.0 1073.5 L 1147.0 1071.5 L 1165.0 1068.5 L 1166.5 1074.0 L 1165.5 1226.0 L 1167.0 1227.5 L 1232.0 1211.5 L 1234.0 1209.5 L 1240.0 1209.5 L 1250.0 1205.5 L 1264.0 1203.5 L 1266.0 1201.5 L 1272.0 1201.5 L 1282.0 1197.5 L 1288.0 1197.5 L 1291.5 1195.0 L 1291.5 925.0 L 1290.5 812.0 L 1289.0 810.5 L 1279.0 813.5 L 1271.0 813.5 L 1269.0 815.5 L 1240.0 819.5 L 1237.0 821.5 L 1218.0 823.5 L 1205.0 827.5 L 1187.0 829.5 L 1184.0 831.5 L 1176.0 831.5 L 1165.5 835.0 L 1165.5 964.0 L 1164.0 965.5 L 1111.0 975.5 L 1108.0 977.5 L 1101.0 977.5 L 1098.0 979.5 L 1091.0 979.5 L 1088.0 981.5 L 1081.0 981.5 L 1078.0 983.5 L 1071.0 983.5 L 1068.0 985.5 L 1061.0 985.5 L 1058.0 987.5 L 1039.0 989.5 L 1036.0 991.5 L 1028.5 990.0 L 1029.5 857.0 L 1028.0 855.5 L 1001.0 859.5 L 998.0 861.5 L 990.0 861.5 L 987.0 863.5 L 978.0 863.5 L 965.0 867.5 L 956.0 867.5 L 943.0 871.5 L 934.0 871.5 L 921.0 875.5 L 900.0 877.5 L 897.5 880.0 L 899.5 1279.0 Z" clip-rule="evenodd"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path d="M 888.0 1477.5 L 867.0 1477.5 L 865.0 1475.5 L 854.0 1473.5 L 812.0 1451.5 L 811.0 1449.5 L 798.0 1443.5 L 720.0 1397.5 L 644.0 1347.5 L 576.0 1299.5 L 563.0 1289.5 L 545.5 1269.0 L 541.5 1257.0 L 540.5 1245.0 L 541.5 669.0 L 545.5 658.0 L 559.0 641.5 L 572.0 633.5 L 589.0 629.5 L 591.0 627.5 L 604.0 625.5 L 606.0 623.5 L 619.0 621.5 L 646.0 613.5 L 652.0 613.5 L 654.0 611.5 L 676.0 607.5 L 678.0 605.5 L 684.0 605.5 L 694.0 601.5 L 700.0 601.5 L 751.0 587.5 L 757.0 587.5 L 759.0 585.5 L 765.0 585.5 L 767.0 583.5 L 773.0 583.5 L 783.0 579.5 L 789.0 579.5 L 791.0 577.5 L 805.0 575.5 L 807.0 573.5 L 813.0 573.5 L 880.0 555.5 L 886.0 555.5 L 888.0 553.5 L 894.0 553.5 L 962.0 535.5 L 968.0 535.5 L 970.0 533.5 L 984.0 531.5 L 987.0 529.5 L 1000.0 527.5 L 1002.0 525.5 L 1008.0 525.5 L 1010.0 523.5 L 1032.0 519.5 L 1034.0 517.5 L 1040.0 517.5 L 1042.0 515.5 L 1064.0 511.5 L 1066.0 509.5 L 1072.0 509.5 L 1090.0 503.5 L 1096.0 503.5 L 1098.0 501.5 L 1104.0 501.5 L 1107.0 499.5 L 1120.0 497.5 L 1122.0 495.5 L 1136.0 493.5 L 1138.0 491.5 L 1144.0 491.5 L 1147.0 489.5 L 1166.0 489.5 L 1175.0 491.5 L 1222.0 513.5 L 1227.0 517.5 L 1245.0 525.5 L 1246.0 527.5 L 1253.0 529.5 L 1273.0 541.5 L 1287.0 547.5 L 1288.0 549.5 L 1291.0 549.5 L 1292.0 551.5 L 1329.0 569.5 L 1330.0 571.5 L 1441.0 629.5 L 1482.0 655.5 L 1497.5 675.0 L 1503.5 692.0 L 1503.5 1264.0 L 1495.5 1281.0 L 1486.0 1291.5 L 1475.0 1299.5 L 1455.0 1307.5 L 1437.0 1311.5 L 1435.0 1313.5 L 1405.0 1321.5 L 1403.0 1323.5 L 1398.0 1323.5 L 1396.0 1325.5 L 1365.0 1333.5 L 1363.0 1335.5 L 1351.0 1337.5 L 1330.0 1345.5 L 1318.0 1347.5 L 1303.0 1353.5 L 1278.0 1359.5 L 1263.0 1365.5 L 1258.0 1365.5 L 1243.0 1371.5 L 1225.0 1375.5 L 1203.0 1383.5 L 1198.0 1383.5 L 1196.0 1385.5 L 1172.0 1391.5 L 1170.0 1393.5 L 1165.0 1393.5 L 1143.0 1401.5 L 1138.0 1401.5 L 1130.0 1405.5 L 1118.0 1407.5 L 1116.0 1409.5 L 1046.0 1429.5 L 1031.0 1435.5 L 1006.0 1441.5 L 991.0 1447.5 L 986.0 1447.5 L 971.0 1453.5 L 966.0 1453.5 L 964.0 1455.5 L 939.0 1461.5 L 937.0 1463.5 L 932.0 1463.5 L 930.0 1465.5 Z M 892.5 1411.0 L 923.0 1403.5 L 925.0 1401.5 L 930.0 1401.5 L 932.0 1399.5 L 937.0 1399.5 L 939.0 1397.5 L 999.0 1381.5 L 1001.0 1379.5 L 1006.0 1379.5 L 1008.0 1377.5 L 1020.0 1375.5 L 1022.0 1373.5 L 1027.0 1373.5 L 1029.0 1371.5 L 1034.0 1371.5 L 1050.0 1365.5 L 1055.0 1365.5 L 1063.0 1361.5 L 1089.0 1355.5 L 1104.0 1349.5 L 1109.0 1349.5 L 1111.0 1347.5 L 1136.0 1341.5 L 1138.0 1339.5 L 1157.0 1335.5 L 1159.0 1333.5 L 1238.0 1311.5 L 1240.0 1309.5 L 1245.0 1309.5 L 1254.0 1305.5 L 1326.0 1285.5 L 1328.0 1283.5 L 1408.0 1261.5 L 1425.0 1255.5 L 1437.5 1245.0 L 1441.5 1239.0 L 1442.5 1232.0 L 1442.5 760.0 L 1441.5 706.0 L 1433.0 693.5 L 1422.0 689.5 L 1403.0 691.5 L 1388.0 695.5 L 1368.0 697.5 L 1365.0 699.5 L 1344.0 701.5 L 1342.0 703.5 L 1308.0 707.5 L 1294.0 711.5 L 1285.0 711.5 L 1282.0 713.5 L 1262.0 715.5 L 1248.0 719.5 L 1215.0 723.5 L 1212.0 725.5 L 1170.0 731.5 L 1156.0 735.5 L 1147.0 735.5 L 1144.0 737.5 L 1135.0 737.5 L 1132.0 739.5 L 1124.0 739.5 L 1098.0 745.5 L 1055.0 751.5 L 1041.0 755.5 L 1021.0 757.5 L 1007.0 761.5 L 1000.0 761.5 L 997.0 763.5 L 977.0 765.5 L 974.0 767.5 L 953.0 769.5 L 950.0 771.5 L 942.0 771.5 L 939.0 773.5 L 931.0 773.5 L 905.0 779.5 L 896.0 779.5 L 881.0 783.5 L 849.0 787.5 L 846.0 789.5 L 805.0 795.5 L 791.0 799.5 L 771.0 801.5 L 761.0 805.5 L 751.5 814.0 L 745.5 827.0 L 745.5 1333.0 L 786.0 1359.5 L 809.0 1371.5 L 861.0 1403.5 L 879.0 1411.5 Z" fill="url(#borderGrad)" fill-rule="evenodd"/>
|
||||||
|
<rect width="2048" height="2048" fill="url(#borderGlow)" clip-path="url(#borderRingClip)"/>
|
||||||
|
|
||||||
|
<path d="M 740.0 749.5 L 729.0 749.5 L 717.0 743.5 L 596.0 675.5 L 591.0 672.5 L 589.5 669.0 L 688.0 643.5 L 694.0 643.5 L 697.0 641.5 L 785.0 621.5 L 796.0 617.5 L 802.0 617.5 L 812.0 613.5 L 818.0 613.5 L 877.0 597.5 L 883.0 597.5 L 910.0 589.5 L 916.0 589.5 L 975.0 573.5 L 981.0 573.5 L 1008.0 565.5 L 1014.0 565.5 L 1057.0 553.5 L 1063.0 553.5 L 1098.0 543.5 L 1145.0 533.5 L 1148.0 531.5 L 1156.0 531.5 L 1195.5 554.0 L 1119.0 573.5 L 1079.0 581.5 L 1052.0 589.5 L 1029.0 593.5 L 1026.0 595.5 L 995.0 601.5 L 950.0 613.5 L 944.0 613.5 L 941.0 615.5 L 909.0 621.5 L 906.0 623.5 L 773.0 653.5 L 751.0 659.5 L 743.5 666.0 L 746.0 669.5 L 757.0 673.5 L 777.0 673.5 L 1227.0 572.5 L 1233.0 573.5 L 1252.0 585.5 L 1269.0 593.5 L 1269.5 596.0 L 1052.0 639.5 L 1039.0 643.5 L 1022.0 645.5 L 1019.0 647.5 L 883.0 673.5 L 880.0 675.5 L 834.0 683.5 L 831.0 685.5 L 810.0 689.5 L 803.5 694.0 L 803.5 698.0 L 811.0 703.5 L 839.0 703.5 L 955.0 679.5 L 1302.0 614.5 L 1305.0 614.5 L 1353.5 640.0 L 1353.0 642.5 L 1340.0 643.5 L 1337.0 645.5 L 1329.0 645.5 L 1325.0 647.5 L 1282.0 653.5 L 1278.0 655.5 L 1269.0 655.5 L 1266.0 657.5 L 1235.0 661.5 L 1232.0 663.5 L 1224.0 663.5 L 1209.0 667.5 L 1177.0 671.5 L 1174.0 673.5 L 1166.0 673.5 L 1094.0 687.5 L 1063.0 691.5 L 1060.0 693.5 L 1052.0 693.5 L 1049.0 695.5 L 1041.0 695.5 L 1037.0 697.5 L 1029.0 697.5 L 1025.0 699.5 L 1017.0 699.5 L 1002.0 703.5 L 960.0 709.5 L 956.0 711.5 L 948.0 711.5 L 945.0 713.5 L 879.0 723.5 L 875.0 725.5 L 834.0 731.5 L 819.0 735.5 L 811.0 735.5 L 785.0 741.5 L 777.0 741.5 Z" fill="url(#topGrad)"/>
|
||||||
|
<path d="M 740.0 749.5 L 729.0 749.5 L 717.0 743.5 L 596.0 675.5 L 591.0 672.5 L 589.5 669.0 L 688.0 643.5 L 694.0 643.5 L 697.0 641.5 L 785.0 621.5 L 796.0 617.5 L 802.0 617.5 L 812.0 613.5 L 818.0 613.5 L 877.0 597.5 L 883.0 597.5 L 910.0 589.5 L 916.0 589.5 L 975.0 573.5 L 981.0 573.5 L 1008.0 565.5 L 1014.0 565.5 L 1057.0 553.5 L 1063.0 553.5 L 1098.0 543.5 L 1145.0 533.5 L 1148.0 531.5 L 1156.0 531.5 L 1195.5 554.0 L 1119.0 573.5 L 1079.0 581.5 L 1052.0 589.5 L 1029.0 593.5 L 1026.0 595.5 L 995.0 601.5 L 950.0 613.5 L 944.0 613.5 L 941.0 615.5 L 909.0 621.5 L 906.0 623.5 L 773.0 653.5 L 751.0 659.5 L 743.5 666.0 L 746.0 669.5 L 757.0 673.5 L 777.0 673.5 L 1227.0 572.5 L 1233.0 573.5 L 1252.0 585.5 L 1269.0 593.5 L 1269.5 596.0 L 1052.0 639.5 L 1039.0 643.5 L 1022.0 645.5 L 1019.0 647.5 L 883.0 673.5 L 880.0 675.5 L 834.0 683.5 L 831.0 685.5 L 810.0 689.5 L 803.5 694.0 L 803.5 698.0 L 811.0 703.5 L 839.0 703.5 L 955.0 679.5 L 1302.0 614.5 L 1305.0 614.5 L 1353.5 640.0 L 1353.0 642.5 L 1340.0 643.5 L 1337.0 645.5 L 1329.0 645.5 L 1325.0 647.5 L 1282.0 653.5 L 1278.0 655.5 L 1269.0 655.5 L 1266.0 657.5 L 1235.0 661.5 L 1232.0 663.5 L 1224.0 663.5 L 1209.0 667.5 L 1177.0 671.5 L 1174.0 673.5 L 1166.0 673.5 L 1094.0 687.5 L 1063.0 691.5 L 1060.0 693.5 L 1052.0 693.5 L 1049.0 695.5 L 1041.0 695.5 L 1037.0 697.5 L 1029.0 697.5 L 1025.0 699.5 L 1017.0 699.5 L 1002.0 703.5 L 960.0 709.5 L 956.0 711.5 L 948.0 711.5 L 945.0 713.5 L 879.0 723.5 L 875.0 725.5 L 834.0 731.5 L 819.0 735.5 L 811.0 735.5 L 785.0 741.5 L 777.0 741.5 Z" fill="none" stroke="#d6ff90" stroke-opacity="0.08" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M 778.0 673.5 L 756.0 673.5 L 752.5 672.0 L 772.5 672.0 L 752.0 669.5 L 748.0 667.5 L 747.5 665.0 L 759.0 659.5 L 773.0 657.5 L 773.0 655.5 L 769.5 655.0 L 773.0 653.5 L 777.5 654.0 L 775.0 655.5 L 782.0 655.5 L 784.0 653.5 L 807.0 649.5 L 809.0 647.5 L 815.0 647.5 L 824.5 644.0 L 791.0 650.5 L 789.0 652.5 L 778.5 653.0 L 781.0 651.5 L 932.0 617.5 L 952.0 611.5 L 967.0 609.5 L 978.0 605.5 L 1168.0 561.5 L 1179.0 557.5 L 1184.5 558.0 L 1182.0 559.5 L 1188.0 559.5 L 1195.0 557.5 L 1196.0 553.5 L 1222.0 567.5 L 1223.5 571.0 L 1219.0 572.5 L 1230.5 572.0 Z" fill="url(#stripeGrad)" opacity="0.96"/>
|
||||||
|
<path d="M 833.0 704.5 L 811.0 703.5 L 808.5 702.0 L 811.5 700.0 L 807.5 699.0 L 806.5 695.0 L 811.0 691.5 L 834.0 687.5 L 836.0 685.5 L 853.0 683.5 L 854.0 681.5 L 807.5 691.0 L 824.0 685.5 L 1048.0 641.5 L 1051.0 639.5 L 1058.0 639.5 L 1071.0 635.5 L 1087.0 633.5 L 1090.0 631.5 L 1127.0 625.5 L 1130.0 623.5 L 1176.0 615.5 L 1258.0 597.5 L 1264.5 598.0 L 1250.5 601.0 L 1270.0 599.5 L 1269.0 597.5 L 1264.5 597.0 L 1271.0 594.5 L 1273.0 597.5 L 1299.5 612.0 L 1289.0 615.5 L 1269.5 618.0 L 1295.5 616.0 L 1292.0 617.5 L 1230.0 627.5 L 1162.0 641.5 L 1155.0 641.5 L 1141.0 645.5 L 1134.0 645.5 L 1068.0 659.5 L 976.0 675.5 L 973.0 677.5 L 966.0 677.5 Z" fill="url(#stripeGrad)" opacity="0.96"/>
|
||||||
|
|
||||||
|
<path d="M 680.0 1300.5 L 634.0 1265.5 L 599.0 1241.5 L 588.5 1232.0 L 587.5 1228.0 L 588.0 670.5 L 592.5 674.0 L 592.0 675.5 L 594.0 674.5 L 596.5 676.0 L 596.0 677.5 L 598.0 676.5 L 599.5 678.0 L 598.5 679.0 L 603.0 679.5 L 603.0 681.5 L 605.0 680.5 L 606.0 683.5 L 608.0 682.5 L 614.0 685.5 L 691.0 728.5 L 696.0 731.5 L 696.0 733.5 L 698.0 732.5 L 710.0 740.5 L 721.0 745.5 L 721.0 747.5 L 723.0 746.5 L 725.0 749.5 L 726.0 748.5 L 728.5 750.0 L 723.0 753.5 L 711.0 757.5 L 694.5 772.0 L 685.5 787.0 L 681.5 800.0 L 681.5 1299.0 Z" fill="url(#leftGrad)"/>
|
||||||
|
|
||||||
|
<path d="M 901.0 1371.5 L 893.0 1371.5 L 877.0 1365.5 L 808.0 1329.5 L 791.5 1318.0 L 791.5 847.0 L 793.5 842.0 L 803.0 833.5 L 818.0 829.5 L 827.0 829.5 L 841.0 825.5 L 850.0 825.5 L 853.0 823.5 L 874.0 821.5 L 877.0 819.5 L 909.0 815.5 L 912.0 813.5 L 932.0 811.5 L 946.0 807.5 L 955.0 807.5 L 969.0 803.5 L 977.0 803.5 L 980.0 801.5 L 1012.0 797.5 L 1015.0 795.5 L 1047.0 791.5 L 1050.0 789.5 L 1059.0 789.5 L 1073.0 785.5 L 1082.0 785.5 L 1085.0 783.5 L 1128.0 777.5 L 1131.0 775.5 L 1140.0 775.5 L 1143.0 773.5 L 1175.0 769.5 L 1189.0 765.5 L 1198.0 765.5 L 1201.0 763.5 L 1234.0 759.5 L 1237.0 757.5 L 1279.0 751.5 L 1282.0 749.5 L 1291.0 749.5 L 1294.0 747.5 L 1302.0 747.5 L 1352.0 737.5 L 1401.0 730.5 L 1401.5 1213.0 L 1397.5 1223.0 L 1389.0 1231.5 L 1373.0 1237.5 L 1368.0 1237.5 L 1366.0 1239.5 L 1361.0 1239.5 L 1359.0 1241.5 L 1333.0 1247.5 L 1324.0 1251.5 L 1319.0 1251.5 L 1317.0 1253.5 L 1312.0 1253.5 L 1310.0 1255.5 L 1285.0 1261.5 L 1283.0 1263.5 L 1277.0 1263.5 L 1275.0 1265.5 L 1256.0 1269.5 L 1241.0 1275.5 L 1236.0 1275.5 L 1187.0 1289.5 L 1185.0 1291.5 L 1165.0 1295.5 L 1163.0 1297.5 L 1158.0 1297.5 L 1149.0 1301.5 L 1144.0 1301.5 L 1142.0 1303.5 L 1109.0 1311.5 L 1107.0 1313.5 L 1088.0 1317.5 L 1086.0 1319.5 L 1067.0 1323.5 L 1058.0 1327.5 L 1052.0 1327.5 L 1050.0 1329.5 L 1015.0 1339.5 L 1010.0 1339.5 L 1008.0 1341.5 L 975.0 1349.5 L 973.0 1351.5 L 968.0 1351.5 L 959.0 1355.5 L 954.0 1355.5 L 952.0 1357.5 L 947.0 1357.5 Z M 906.5 1279.0 L 957.0 1265.5 L 979.0 1261.5 L 981.0 1259.5 L 986.0 1259.5 L 997.0 1255.5 L 1027.0 1249.5 L 1029.5 1247.0 L 1029.5 1100.0 L 1032.0 1097.5 L 1037.0 1097.5 L 1040.0 1095.5 L 1056.0 1093.5 L 1058.0 1091.5 L 1064.0 1091.5 L 1076.0 1087.5 L 1083.0 1087.5 L 1111.0 1079.5 L 1145.0 1073.5 L 1147.0 1071.5 L 1165.0 1068.5 L 1166.5 1074.0 L 1165.5 1226.0 L 1167.0 1227.5 L 1232.0 1211.5 L 1234.0 1209.5 L 1240.0 1209.5 L 1250.0 1205.5 L 1264.0 1203.5 L 1266.0 1201.5 L 1272.0 1201.5 L 1282.0 1197.5 L 1288.0 1197.5 L 1291.5 1195.0 L 1291.5 925.0 L 1290.5 812.0 L 1289.0 810.5 L 1279.0 813.5 L 1271.0 813.5 L 1269.0 815.5 L 1240.0 819.5 L 1237.0 821.5 L 1218.0 823.5 L 1205.0 827.5 L 1187.0 829.5 L 1184.0 831.5 L 1176.0 831.5 L 1165.5 835.0 L 1165.5 964.0 L 1164.0 965.5 L 1111.0 975.5 L 1108.0 977.5 L 1101.0 977.5 L 1098.0 979.5 L 1091.0 979.5 L 1088.0 981.5 L 1081.0 981.5 L 1078.0 983.5 L 1071.0 983.5 L 1068.0 985.5 L 1061.0 985.5 L 1058.0 987.5 L 1039.0 989.5 L 1036.0 991.5 L 1028.5 990.0 L 1029.5 857.0 L 1028.0 855.5 L 1001.0 859.5 L 998.0 861.5 L 990.0 861.5 L 987.0 863.5 L 978.0 863.5 L 965.0 867.5 L 956.0 867.5 L 943.0 871.5 L 934.0 871.5 L 921.0 875.5 L 900.0 877.5 L 897.5 880.0 L 899.5 1279.0 Z" fill="url(#frontGrad)" fill-rule="evenodd"/>
|
||||||
|
<rect width="2048" height="2048" fill="url(#frontHighlight)" clip-path="url(#panelClip)"/>
|
||||||
|
<path d="M 900.0 1278.0 L 1030.0 1247.0 L 1030.0 1100.0 L 1165.0 1071.0 L 1165.0 1227.0 L 1292.0 1195.0 L 1292.0 1250.0 L 980.0 1348.0 L 900.0 1316.0 Z" fill="url(#panelShadowGrad)"/>
|
||||||
|
<path d="M 901.0 1371.5 L 893.0 1371.5 L 877.0 1365.5 L 808.0 1329.5 L 791.5 1318.0 L 791.5 847.0 L 793.5 842.0 L 803.0 833.5 L 818.0 829.5 L 827.0 829.5 L 841.0 825.5 L 850.0 825.5 L 853.0 823.5 L 874.0 821.5 L 877.0 819.5 L 909.0 815.5 L 912.0 813.5 L 932.0 811.5 L 946.0 807.5 L 955.0 807.5 L 969.0 803.5 L 977.0 803.5 L 980.0 801.5 L 1012.0 797.5 L 1015.0 795.5 L 1047.0 791.5 L 1050.0 789.5 L 1059.0 789.5 L 1073.0 785.5 L 1082.0 785.5 L 1085.0 783.5 L 1128.0 777.5 L 1131.0 775.5 L 1140.0 775.5 L 1143.0 773.5 L 1175.0 769.5 L 1189.0 765.5 L 1198.0 765.5 L 1201.0 763.5 L 1234.0 759.5 L 1237.0 757.5 L 1279.0 751.5 L 1282.0 749.5 L 1291.0 749.5 L 1294.0 747.5 L 1302.0 747.5 L 1352.0 737.5 L 1401.0 730.5 L 1401.5 1213.0 L 1397.5 1223.0 L 1389.0 1231.5 L 1373.0 1237.5 L 1368.0 1237.5 L 1366.0 1239.5 L 1361.0 1239.5 L 1359.0 1241.5 L 1333.0 1247.5 L 1324.0 1251.5 L 1319.0 1251.5 L 1317.0 1253.5 L 1312.0 1253.5 L 1310.0 1255.5 L 1285.0 1261.5 L 1283.0 1263.5 L 1277.0 1263.5 L 1275.0 1265.5 L 1256.0 1269.5 L 1241.0 1275.5 L 1236.0 1275.5 L 1187.0 1289.5 L 1185.0 1291.5 L 1165.0 1295.5 L 1163.0 1297.5 L 1158.0 1297.5 L 1149.0 1301.5 L 1144.0 1301.5 L 1142.0 1303.5 L 1109.0 1311.5 L 1107.0 1313.5 L 1088.0 1317.5 L 1086.0 1319.5 L 1067.0 1323.5 L 1058.0 1327.5 L 1052.0 1327.5 L 1050.0 1329.5 L 1015.0 1339.5 L 1010.0 1339.5 L 1008.0 1341.5 L 975.0 1349.5 L 973.0 1351.5 L 968.0 1351.5 L 959.0 1355.5 L 954.0 1355.5 L 952.0 1357.5 L 947.0 1357.5 Z M 906.5 1279.0 L 957.0 1265.5 L 979.0 1261.5 L 981.0 1259.5 L 986.0 1259.5 L 997.0 1255.5 L 1027.0 1249.5 L 1029.5 1247.0 L 1029.5 1100.0 L 1032.0 1097.5 L 1037.0 1097.5 L 1040.0 1095.5 L 1056.0 1093.5 L 1058.0 1091.5 L 1064.0 1091.5 L 1076.0 1087.5 L 1083.0 1087.5 L 1111.0 1079.5 L 1145.0 1073.5 L 1147.0 1071.5 L 1165.0 1068.5 L 1166.5 1074.0 L 1165.5 1226.0 L 1167.0 1227.5 L 1232.0 1211.5 L 1234.0 1209.5 L 1240.0 1209.5 L 1250.0 1205.5 L 1264.0 1203.5 L 1266.0 1201.5 L 1272.0 1201.5 L 1282.0 1197.5 L 1288.0 1197.5 L 1291.5 1195.0 L 1291.5 925.0 L 1290.5 812.0 L 1289.0 810.5 L 1279.0 813.5 L 1271.0 813.5 L 1269.0 815.5 L 1240.0 819.5 L 1237.0 821.5 L 1218.0 823.5 L 1205.0 827.5 L 1187.0 829.5 L 1184.0 831.5 L 1176.0 831.5 L 1165.5 835.0 L 1165.5 964.0 L 1164.0 965.5 L 1111.0 975.5 L 1108.0 977.5 L 1101.0 977.5 L 1098.0 979.5 L 1091.0 979.5 L 1088.0 981.5 L 1081.0 981.5 L 1078.0 983.5 L 1071.0 983.5 L 1068.0 985.5 L 1061.0 985.5 L 1058.0 987.5 L 1039.0 989.5 L 1036.0 991.5 L 1028.5 990.0 L 1029.5 857.0 L 1028.0 855.5 L 1001.0 859.5 L 998.0 861.5 L 990.0 861.5 L 987.0 863.5 L 978.0 863.5 L 965.0 867.5 L 956.0 867.5 L 943.0 871.5 L 934.0 871.5 L 921.0 875.5 L 900.0 877.5 L 897.5 880.0 L 899.5 1279.0 Z" fill="none" stroke="#2fe56b" stroke-opacity="0.05" stroke-width="3" stroke-linejoin="round" fill-rule="evenodd"/>
|
||||||
|
|
||||||
|
<path d="M 888.0 1477.5 L 867.0 1477.5 L 865.0 1475.5 L 854.0 1473.5 L 812.0 1451.5 L 811.0 1449.5 L 798.0 1443.5 L 720.0 1397.5 L 644.0 1347.5 L 576.0 1299.5 L 563.0 1289.5 L 545.5 1269.0 L 541.5 1257.0 L 540.5 1245.0 L 541.5 669.0 L 545.5 658.0 L 559.0 641.5 L 572.0 633.5 L 589.0 629.5 L 591.0 627.5 L 604.0 625.5 L 606.0 623.5 L 619.0 621.5 L 646.0 613.5 L 652.0 613.5 L 654.0 611.5 L 676.0 607.5 L 678.0 605.5 L 684.0 605.5 L 694.0 601.5 L 700.0 601.5 L 751.0 587.5 L 757.0 587.5 L 759.0 585.5 L 765.0 585.5 L 767.0 583.5 L 773.0 583.5 L 783.0 579.5 L 789.0 579.5 L 791.0 577.5 L 805.0 575.5 L 807.0 573.5 L 813.0 573.5 L 880.0 555.5 L 886.0 555.5 L 888.0 553.5 L 894.0 553.5 L 962.0 535.5 L 968.0 535.5 L 970.0 533.5 L 984.0 531.5 L 987.0 529.5 L 1000.0 527.5 L 1002.0 525.5 L 1008.0 525.5 L 1010.0 523.5 L 1032.0 519.5 L 1034.0 517.5 L 1040.0 517.5 L 1042.0 515.5 L 1064.0 511.5 L 1066.0 509.5 L 1072.0 509.5 L 1090.0 503.5 L 1096.0 503.5 L 1098.0 501.5 L 1104.0 501.5 L 1107.0 499.5 L 1120.0 497.5 L 1122.0 495.5 L 1136.0 493.5 L 1138.0 491.5 L 1144.0 491.5 L 1147.0 489.5 L 1166.0 489.5 L 1175.0 491.5 L 1222.0 513.5 L 1227.0 517.5 L 1245.0 525.5 L 1246.0 527.5 L 1253.0 529.5 L 1273.0 541.5 L 1287.0 547.5 L 1288.0 549.5 L 1291.0 549.5 L 1292.0 551.5 L 1329.0 569.5 L 1330.0 571.5 L 1441.0 629.5 L 1482.0 655.5 L 1497.5 675.0 L 1503.5 692.0 L 1503.5 1264.0 L 1495.5 1281.0 L 1486.0 1291.5 L 1475.0 1299.5 L 1455.0 1307.5 L 1437.0 1311.5 L 1435.0 1313.5 L 1405.0 1321.5 L 1403.0 1323.5 L 1398.0 1323.5 L 1396.0 1325.5 L 1365.0 1333.5 L 1363.0 1335.5 L 1351.0 1337.5 L 1330.0 1345.5 L 1318.0 1347.5 L 1303.0 1353.5 L 1278.0 1359.5 L 1263.0 1365.5 L 1258.0 1365.5 L 1243.0 1371.5 L 1225.0 1375.5 L 1203.0 1383.5 L 1198.0 1383.5 L 1196.0 1385.5 L 1172.0 1391.5 L 1170.0 1393.5 L 1165.0 1393.5 L 1143.0 1401.5 L 1138.0 1401.5 L 1130.0 1405.5 L 1118.0 1407.5 L 1116.0 1409.5 L 1046.0 1429.5 L 1031.0 1435.5 L 1006.0 1441.5 L 991.0 1447.5 L 986.0 1447.5 L 971.0 1453.5 L 966.0 1453.5 L 964.0 1455.5 L 939.0 1461.5 L 937.0 1463.5 L 932.0 1463.5 L 930.0 1465.5 Z" fill="none" stroke="#00efc3" stroke-opacity="0.06" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
initials: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span>{{ initials }}</span>
|
||||||
|
</template>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,798 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
Hoard – Globale Tokens, Resets und Vuetify-Anpassungen
|
||||||
|
Quelle: GUI/style.md
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
|
--color-bg: #f5f8f2;
|
||||||
|
--color-bg-tint: #eef3ea;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-alt: #f1f4ee;
|
||||||
|
--color-surface-elevated: #fbfcf8;
|
||||||
|
|
||||||
|
--color-border: #dce3d6;
|
||||||
|
--color-border-strong: #c5cfbe;
|
||||||
|
--color-border-subtle: #e8ede2;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text: #1a2a1e;
|
||||||
|
--color-text-secondary: #5a6a5e;
|
||||||
|
--color-text-muted: #7b897f;
|
||||||
|
--color-text-on-primary: #ffffff;
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
--color-primary-800: #10421e;
|
||||||
|
--color-primary-700: #1c652f;
|
||||||
|
--color-primary-600: #2e7d32;
|
||||||
|
--color-primary-500: #3c8f42;
|
||||||
|
--color-primary-300: #a8d5a2;
|
||||||
|
--color-primary-100: #eaf5e8;
|
||||||
|
--color-primary-050: #f4faf1;
|
||||||
|
--color-accent-lime: #b7e36b;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--color-success: #2e7d32;
|
||||||
|
--color-warning: #b7791f;
|
||||||
|
--color-danger: #c0392b;
|
||||||
|
--color-info: #2f6fb3;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius-xs: 6px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--radius-md: 14px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--radius-xl: 22px;
|
||||||
|
--radius-full: 999px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--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-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-7: 28px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-16: 64px;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-family-sans:
|
||||||
|
'Inter', 'Segoe UI', Roboto, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
'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-sm: 13px;
|
||||||
|
--font-size-md: 14px;
|
||||||
|
--font-size-lg: 16px;
|
||||||
|
--font-size-xl: 20px;
|
||||||
|
--font-size-2xl: 26px;
|
||||||
|
--font-size-3xl: 32px;
|
||||||
|
--font-size-display: 44px;
|
||||||
|
|
||||||
|
--line-height-tight: 1.2;
|
||||||
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-loose: 1.65;
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
|
||||||
|
/* 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'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--color-bg: #0e1115;
|
||||||
|
--color-bg-tint: #11161c;
|
||||||
|
--color-surface: #161a20;
|
||||||
|
--color-surface-alt: #1b2028;
|
||||||
|
--color-surface-elevated: #1f252e;
|
||||||
|
|
||||||
|
--color-border: #2a323d;
|
||||||
|
--color-border-strong: #3a4452;
|
||||||
|
--color-border-subtle: #232a34;
|
||||||
|
|
||||||
|
--color-text: #e9eff3;
|
||||||
|
--color-text-secondary: #b6bec8;
|
||||||
|
--color-text-muted: #8b94a0;
|
||||||
|
--color-text-on-primary: #08120a;
|
||||||
|
|
||||||
|
--color-primary-800: #8be194;
|
||||||
|
--color-primary-700: #5fb968;
|
||||||
|
--color-primary-600: #4ea758;
|
||||||
|
--color-primary-500: #3f9148;
|
||||||
|
--color-primary-300: #2e6a37;
|
||||||
|
--color-primary-100: #1f2922;
|
||||||
|
--color-primary-050: #161e19;
|
||||||
|
--color-accent-lime: #b7e36b;
|
||||||
|
|
||||||
|
--color-success: #5fb968;
|
||||||
|
--color-warning: #d0a34e;
|
||||||
|
--color-danger: #e07a7a;
|
||||||
|
--color-info: #6aa8de;
|
||||||
|
|
||||||
|
--shadow-xs: 0 1px 2px rgb(0 0 0 / 30%);
|
||||||
|
--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-ambient:
|
||||||
|
radial-gradient(
|
||||||
|
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,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: var(--line-height-normal);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--page-bg-ambient);
|
||||||
|
background-attachment: fixed;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary-300) 42%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: var(--line-height-loose);
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre {
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(a, button, input, textarea, select, [tabindex]):focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary-500);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- App shell ---------- */
|
||||||
|
.v-application {
|
||||||
|
font-family: var(--font-family-sans) !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-main {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-app-bar {
|
||||||
|
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;
|
||||||
|
box-shadow: var(--shadow-xs) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer {
|
||||||
|
background-color: var(--color-surface-alt) !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 {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
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 {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary-100) 50%,
|
||||||
|
var(--color-surface-alt) 50%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer .v-list-item--active {
|
||||||
|
color: var(--color-primary-700) !important;
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary-100) 75%,
|
||||||
|
var(--color-surface) 25%
|
||||||
|
) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer .v-list-item--active::before {
|
||||||
|
height: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Cards / surfaces ---------- */
|
||||||
|
.v-card {
|
||||||
|
border: 1px solid var(--color-border) !important;
|
||||||
|
border-radius: var(--radius-lg) !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;
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card-title {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
font-size: var(--font-size-xl) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-card-text {
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-footer {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
background-color: color-mix(in srgb, var(--color-surface) 92%, var(--color-bg) 8%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
.v-btn {
|
||||||
|
letter-spacing: 0 !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
border-radius: var(--radius-sm) !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-flat {
|
||||||
|
box-shadow: var(--shadow-xs) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--variant-elevated:not(.v-btn--disabled):hover,
|
||||||
|
.v-btn--variant-flat:not(.v-btn--disabled):hover {
|
||||||
|
box-shadow: var(--shadow-sm) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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-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 {
|
||||||
|
border-color: var(--color-border-strong) !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 88%,
|
||||||
|
var(--color-surface-alt) 12%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
border-radius: var(--radius-md) !important;
|
||||||
|
background-color: var(--color-surface) !important;
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-input .v-field__outline {
|
||||||
|
--v-field-border-opacity: 1;
|
||||||
|
color: var(--color-border-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-input.v-input--focused .v-field__outline {
|
||||||
|
color: var(--color-primary-600) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-label {
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-surface) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table thead th {
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
font-weight: 600 !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 {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary-100) 32%,
|
||||||
|
var(--color-surface) 68%
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-table tbody td {
|
||||||
|
border-bottom-color: var(--color-border-subtle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Status pills ---------- */
|
||||||
|
.ui-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 3px var(--space-2);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 14%, var(--color-surface) 86%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status--warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 16%, var(--color-surface) 84%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status--danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
background-color: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface) 86%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status--info {
|
||||||
|
color: var(--color-info);
|
||||||
|
background-color: color-mix(in srgb, var(--color-info) 14%, var(--color-surface) 86%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-status--muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
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-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
background-color: var(--color-surface-alt);
|
||||||
|
border-top-left-radius: inherit;
|
||||||
|
border-top-right-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-list-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 2fr) minmax(120px, 1fr) minmax(100px, 1fr) minmax(120px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-list-row:hover {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary-100) 32%,
|
||||||
|
var(--color-surface) 68%
|
||||||
|
);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-list-row.is-selected {
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary-100) 70%,
|
||||||
|
var(--color-surface) 30%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-meta {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-empty-state {
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-empty-state h2 {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-empty-state p {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 44ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Scrollbar ---------- */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: var(--scrollbar-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
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) {
|
||||||
|
:root {
|
||||||
|
--ui-mobile-safe-left: max(var(--space-2), env(safe-area-inset-left));
|
||||||
|
--ui-mobile-safe-right: max(var(--space-2), env(safe-area-inset-right));
|
||||||
|
--ui-mobile-safe-bottom: max(var(--space-3), env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-main {
|
||||||
|
padding-bottom: var(--ui-mobile-safe-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--icon.v-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer .v-list-item {
|
||||||
|
min-height: 48px;
|
||||||
|
margin: 4px var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-list-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: start;
|
||||||
|
padding-block: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-empty-state {
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer {
|
||||||
|
border-right: none !important;
|
||||||
|
border-top: 1px solid var(--color-border) !important;
|
||||||
|
padding-bottom: var(--ui-mobile-safe-bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.ui-toolbar {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-list-row {
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-meta {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+6
-1
@@ -1,12 +1,17 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import './global.css'
|
||||||
|
import './styles/global/page-layouts.css'
|
||||||
|
import './styles/global/surface-patterns.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './Layout.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import vuetify from './plugins/vuetify'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(vuetify)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import Home from '@/routes/Home.vue'
|
||||||
|
import Dashboard from '@/routes/dashboard/Dashboard.vue'
|
||||||
|
import NotFound from '@/routes/404NotFound.vue'
|
||||||
|
import Forbidden from '@/routes/Forbidden.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 { ROLE_ADMIN } from '@/services/authSession'
|
||||||
|
|
||||||
|
export enum Visibility {
|
||||||
|
Hidden,
|
||||||
|
Authenticated,
|
||||||
|
Unauthenticated,
|
||||||
|
Authorized,
|
||||||
|
Public,
|
||||||
|
Footer,
|
||||||
|
Route,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutRoute {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
disableFooter?: boolean
|
||||||
|
visible: Visibility
|
||||||
|
visibilityRoute?: string | string[]
|
||||||
|
requiredRoles?: string[]
|
||||||
|
meta?: RouteRecordRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzanleitung fuer Sidebar-Sichtbarkeit:
|
||||||
|
* - `Visibility.Public`: Eintrag ist immer in der Sidebar sichtbar.
|
||||||
|
* - `Visibility.Route`: Eintrag ist nur sichtbar, wenn die aktuelle URL im angegebenen Bereich liegt.
|
||||||
|
* - Ohne `visibilityRoute` wird automatisch `path` als Bereich verwendet.
|
||||||
|
* - Szenario 1: `path: '/dash'` -> sichtbar bei `/dash` und `/dash/*`.
|
||||||
|
* - Szenario 2: `visibilityRoute: '/admin'` -> Eintrag wird nur im Admin-Bereich gezeigt.
|
||||||
|
* - Szenario 3: `visibilityRoute: ['/dash', '/projects']` -> sichtbar in beiden Bereichen.
|
||||||
|
* - `Visibility.Footer` und `Visibility.Hidden`: nicht in der Sidebar sichtbar.
|
||||||
|
*/
|
||||||
|
export const routes: LayoutRoute[] = [
|
||||||
|
{
|
||||||
|
path: '/welcome',
|
||||||
|
name: 'Startseite',
|
||||||
|
description: 'Self-hosted Datei-Workspace für Hoard',
|
||||||
|
icon: 'mdi-home',
|
||||||
|
visible: Visibility.Unauthenticated,
|
||||||
|
meta: {
|
||||||
|
name: 'Home',
|
||||||
|
path: '/welcome',
|
||||||
|
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',
|
||||||
|
name: 'Login',
|
||||||
|
description: 'Logge dich ein',
|
||||||
|
icon: 'mdi-login',
|
||||||
|
visible: Visibility.Unauthenticated,
|
||||||
|
meta: {
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login,
|
||||||
|
meta: {
|
||||||
|
guestOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/impressum',
|
||||||
|
name: 'Impressum',
|
||||||
|
description: 'Impressum der Anwendung',
|
||||||
|
icon: 'mdi-file-document',
|
||||||
|
visible: Visibility.Footer,
|
||||||
|
meta: {
|
||||||
|
path: '/impressum',
|
||||||
|
name: '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',
|
||||||
|
name: 'Nicht gefunden',
|
||||||
|
description: 'Diese Seite wurde nicht gefunden',
|
||||||
|
icon: 'mdi-information-outline',
|
||||||
|
visible: Visibility.Hidden,
|
||||||
|
meta: { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'vuetify/styles'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
|
import { aliases, mdi } from 'vuetify/iconsets/mdi'
|
||||||
|
|
||||||
|
export default createVuetify({
|
||||||
|
components,
|
||||||
|
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: {
|
||||||
|
defaultTheme: 'light',
|
||||||
|
themes: {
|
||||||
|
light: {
|
||||||
|
dark: false,
|
||||||
|
colors: {
|
||||||
|
primary: '#1C652F',
|
||||||
|
'primary-darken-1': '#10421E',
|
||||||
|
secondary: '#5A6A5E',
|
||||||
|
accent: '#B7E36B',
|
||||||
|
background: '#F5F8F2',
|
||||||
|
surface: '#FFFFFF',
|
||||||
|
'surface-variant': '#F1F4EE',
|
||||||
|
success: '#2E7D32',
|
||||||
|
warning: '#B7791F',
|
||||||
|
error: '#C0392B',
|
||||||
|
info: '#2F6FB3',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
dark: true,
|
||||||
|
colors: {
|
||||||
|
primary: '#5FB968',
|
||||||
|
'primary-darken-1': '#4EA758',
|
||||||
|
secondary: '#B6BEC8',
|
||||||
|
accent: '#B7E36B',
|
||||||
|
background: '#0E1115',
|
||||||
|
surface: '#161A20',
|
||||||
|
'surface-variant': '#1B2028',
|
||||||
|
success: '#5FB968',
|
||||||
|
warning: '#D0A34E',
|
||||||
|
error: '#E07A7A',
|
||||||
|
info: '#6AA8DE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
defaultSet: 'mdi',
|
||||||
|
aliases,
|
||||||
|
sets: {
|
||||||
|
mdi,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
+66
-2
@@ -1,8 +1,72 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
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: 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
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const
|
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import notFoundImage from '@/assets/images/404NotFound.png'
|
||||||
|
import { fetchCurrentUser } from '@/services/authSession'
|
||||||
|
|
||||||
|
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() {
|
||||||
|
clearAutoRedirectTimer()
|
||||||
|
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void router.push({ name: redirectRouteName.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void resolveRedirectTarget()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearAutoRedirectTimer()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="not-found-page ui-page ui-page--centered">
|
||||||
|
<section class="not-found-shell ui-panel ui-panel-gradient ui-spotlight ui-shell-grid">
|
||||||
|
<div class="not-found-visual">
|
||||||
|
<div class="image-frame">
|
||||||
|
<img
|
||||||
|
:src="notFoundImage"
|
||||||
|
alt="Illustration für eine nicht gefundene Seite"
|
||||||
|
class="not-found-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="not-found-content">
|
||||||
|
<p class="not-found-code">404</p>
|
||||||
|
<p class="ui-kicker ui-kicker--wide">Seite nicht gefunden</p>
|
||||||
|
<h1>Diese Spur führt ins Leere.</h1>
|
||||||
|
<p class="not-found-text">
|
||||||
|
Der Link ist ungültig oder die Seite wurde verschoben. Wir leiten dich gleich zur
|
||||||
|
{{ autoRedirectTargetLabel }} weiter – oder du nutzt direkt einen der Buttons unten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="not-found-actions ui-action-row">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<p class="not-found-hint">
|
||||||
|
<v-icon icon="mdi-clock-time-four-outline" size="14" />
|
||||||
|
Auto-Redirect zur {{ autoRedirectTargetLabel }} in wenigen Sekunden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.not-found-shell {
|
||||||
|
--ui-shell-width: 1040px;
|
||||||
|
--ui-gradient-angle: 130deg;
|
||||||
|
--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%;
|
||||||
|
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-visual {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-frame {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 380px);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background:
|
||||||
|
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 {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-code {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: 88px;
|
||||||
|
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 {
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
max-width: 48ch;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-actions {
|
||||||
|
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) {
|
||||||
|
.not-found-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-frame {
|
||||||
|
width: min(100%, 320px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-content {
|
||||||
|
text-align: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-actions {
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.not-found-actions .v-btn) {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.not-found-code {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-text {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-frame {
|
||||||
|
width: min(100%, 260px);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.not-found-actions .v-btn) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,785 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import iconImage from '@/assets/images/icon.svg'
|
||||||
|
|
||||||
|
const valueProps = [
|
||||||
|
{
|
||||||
|
icon: 'mdi-folder-multiple-outline',
|
||||||
|
title: 'Dateien zuerst',
|
||||||
|
text: 'Ordner, Dateiliste und Vorschau bilden den Kern – kein überladenes Dashboard, keine Ablenkung.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-file-document-edit-outline',
|
||||||
|
title: 'Markdown direkt im Browser',
|
||||||
|
text: 'Notizen, Doku und Konzepte ohne Tool-Wechsel öffnen, lesen und sauber strukturieren.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-server-outline',
|
||||||
|
title: 'Self-hosted Kontrolle',
|
||||||
|
text: 'Metadaten in PostgreSQL, Dateien in MinIO. Volle Datenhoheit auf deinem eigenen Server.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const coreFeatures = [
|
||||||
|
{
|
||||||
|
icon: 'mdi-folder-open-outline',
|
||||||
|
title: 'Ordnernavigation wie gewohnt',
|
||||||
|
text: 'Schnell durch Verzeichnisse klicken, Inhalte erfassen und sauber organisieren.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-image-outline',
|
||||||
|
title: 'PDF- und Bildvorschau',
|
||||||
|
text: 'Dateien direkt einsehen, ohne externe Viewer oder ständige Downloads.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-account-lock-outline',
|
||||||
|
title: 'Klare Benutzerlogik',
|
||||||
|
text: 'Keine offene Registrierung. Konten werden bewusst und kontrolliert verwaltet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-lightning-bolt-outline',
|
||||||
|
title: 'Schlankes MVP-Setup',
|
||||||
|
text: 'Fokus auf das Wesentliche – stabil, wartbar und realistisch für Solo-Entwicklung.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const workflowSteps = [
|
||||||
|
{
|
||||||
|
number: '01',
|
||||||
|
title: 'Anmelden',
|
||||||
|
text: 'Mit deinem bestehenden Konto einsteigen und direkt in deiner Dateiablage starten.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '02',
|
||||||
|
title: 'Dateien strukturieren',
|
||||||
|
text: 'Ordner anlegen, Inhalte hochladen und Arbeitsbereiche aufgeräumt halten.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '03',
|
||||||
|
title: 'Inhalte bearbeiten',
|
||||||
|
text: 'Markdown, Bilder und PDFs direkt in der App ansehen und bearbeiten.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="landing-page ui-page">
|
||||||
|
<section class="hero ui-panel ui-panel-gradient ui-spotlight">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="hero-kicker ui-kicker ui-kicker--wide">Self-hosted Datei-Workspace</p>
|
||||||
|
<h1>
|
||||||
|
Eine ruhige Heimat für deine
|
||||||
|
<span class="hero-accent">Dateien, Ordner und Notizen.</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-lead">
|
||||||
|
Hoard ist eine Google-Drive-inspirierte Web-App für Teams, die volle Kontrolle über
|
||||||
|
Daten, Struktur und Workflow behalten wollen – ohne Cloud-Lock-in, ohne SaaS-Abo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="hero-actions ui-action-row">
|
||||||
|
<v-btn variant="elevated" size="large" prepend-icon="mdi-login" to="/login">
|
||||||
|
Anmelden
|
||||||
|
</v-btn>
|
||||||
|
<v-btn variant="outlined" size="large" prepend-icon="mdi-arrow-right" to="/impressum">
|
||||||
|
Mehr erfahren
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-tags">
|
||||||
|
<span class="ui-chip ui-chip--brand">
|
||||||
|
<v-icon icon="mdi-shield-check-outline" size="14" /> Datenhoheit
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<aside class="hero-preview" aria-hidden="true">
|
||||||
|
<div class="preview-window">
|
||||||
|
<header class="preview-window__head">
|
||||||
|
<span class="preview-window__dots">
|
||||||
|
<span /><span /><span />
|
||||||
|
</span>
|
||||||
|
<span class="preview-window__path">
|
||||||
|
hoard / dokumentation
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-glow" aria-hidden="true">
|
||||||
|
<img :src="iconImage" alt="" class="preview-glow__logo" />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="value-grid">
|
||||||
|
<article v-for="item in valueProps" :key="item.title" class="value-card ui-panel">
|
||||||
|
<span class="ui-icon-tile ui-icon-tile--lg">
|
||||||
|
<v-icon :icon="item.icon" size="22" />
|
||||||
|
</span>
|
||||||
|
<h2>{{ item.title }}</h2>
|
||||||
|
<p>{{ item.text }}</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="feature-section ui-panel">
|
||||||
|
<header class="ui-section-head">
|
||||||
|
<p class="ui-kicker">Für den Produktivalltag</p>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="feature-grid">
|
||||||
|
<article v-for="feature in coreFeatures" :key="feature.title" class="feature-card">
|
||||||
|
<span class="ui-icon-tile">
|
||||||
|
<v-icon :icon="feature.icon" size="20" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3>{{ feature.title }}</h3>
|
||||||
|
<p>{{ feature.text }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workflow-section">
|
||||||
|
<header class="ui-section-head">
|
||||||
|
<p class="ui-kicker">So funktioniert Hoard</p>
|
||||||
|
<h2>In drei klaren Schritten produktiv starten.</h2>
|
||||||
|
</header>
|
||||||
|
<div class="workflow-grid">
|
||||||
|
<article v-for="step in workflowSteps" :key="step.number" class="workflow-card ui-panel">
|
||||||
|
<p class="workflow-number">{{ step.number }}</p>
|
||||||
|
<h3>{{ step.title }}</h3>
|
||||||
|
<p>{{ step.text }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack-section ui-panel ui-panel-gradient">
|
||||||
|
<div class="stack-copy">
|
||||||
|
<p class="ui-kicker">Technische Basis</p>
|
||||||
|
<h2>Schlank gebaut für ein realistisches MVP.</h2>
|
||||||
|
<p>
|
||||||
|
Hoard kombiniert einen modernen Frontend-Stack mit einem pragmatischen Backend-Setup,
|
||||||
|
damit Weiterentwicklung und Betrieb auch solo machbar bleiben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stack-list">
|
||||||
|
<span v-for="item in techStack" :key="item.label" class="stack-pill">
|
||||||
|
<v-icon :icon="item.icon" size="16" />
|
||||||
|
{{ item.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.landing-page {
|
||||||
|
--ui-page-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Hero ---------- */
|
||||||
|
.hero {
|
||||||
|
--ui-gradient-angle: 130deg;
|
||||||
|
--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%;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-10);
|
||||||
|
padding: var(--space-12);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
max-width: 18ch;
|
||||||
|
font-size: clamp(2.4rem, 1.6rem + 1.6vw, 3.2rem);
|
||||||
|
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 {
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
max-width: 56ch;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Hero preview ---------- */
|
||||||
|
.hero-preview {
|
||||||
|
position: relative;
|
||||||
|
align-self: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-toolbar__title {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row__meta {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-card {
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-card,
|
||||||
|
.workflow-card,
|
||||||
|
.feature-card {
|
||||||
|
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,
|
||||||
|
.feature-card p,
|
||||||
|
.workflow-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Feature section ---------- */
|
||||||
|
.feature-section {
|
||||||
|
padding: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
display: grid;
|
||||||
|
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);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3,
|
||||||
|
.workflow-card h3 {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Workflow ---------- */
|
||||||
|
.workflow-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card {
|
||||||
|
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 {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Stack ---------- */
|
||||||
|
.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;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-8);
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 8px var(--space-4);
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
transition: transform var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pill:hover {
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
gap: var(--space-8);
|
||||||
|
padding: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-preview {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 960px) {
|
||||||
|
.hero,
|
||||||
|
.feature-section,
|
||||||
|
.stack-section {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-preview {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window__body {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-grid,
|
||||||
|
.feature-grid,
|
||||||
|
.workflow-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.hero,
|
||||||
|
.feature-section,
|
||||||
|
.stack-section,
|
||||||
|
.feature-card,
|
||||||
|
.value-card,
|
||||||
|
.workflow-card {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-lead {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.hero-actions .v-btn) {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-tags {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-pill {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-row .ui-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const companyDetails = [
|
||||||
|
{ label: 'Anbieter', value: 'Hoard Labs GmbH (Testdaten)' },
|
||||||
|
{ label: 'Vertreten durch', value: 'Max Mustermann, Geschäftsführer' },
|
||||||
|
{ label: 'Adresse', value: 'Musterstraße 42, 12345 Musterstadt, Deutschland' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const registerDetails = [
|
||||||
|
{ label: 'Handelsregister', value: 'HRB 123456' },
|
||||||
|
{ label: 'Registergericht', value: 'Amtsgericht Musterstadt' },
|
||||||
|
{ label: 'Umsatzsteuer-ID', value: 'DE123456789' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const contactDetails = [
|
||||||
|
{ label: 'Telefon', value: '+49 30 1234567-0', href: 'tel:+493012345670' },
|
||||||
|
{ label: 'E-Mail', value: 'kontakt@ui-demo.de', href: 'mailto:kontakt@ui-demo.de' },
|
||||||
|
{ label: 'Support', value: 'support@ui-demo.de', href: 'mailto:support@ui-demo.de' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const legalNotes = [
|
||||||
|
{
|
||||||
|
icon: 'mdi-account-voice',
|
||||||
|
title: 'Verantwortlich für den Inhalt',
|
||||||
|
text: 'Julia Beispiel, Musterstraße 42, 12345 Musterstadt, Deutschland (Testdaten).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-scale-balance',
|
||||||
|
title: 'EU-Streitbeilegung',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="impressum-page ui-page">
|
||||||
|
<section class="impressum-hero ui-panel ui-panel-gradient ui-spotlight">
|
||||||
|
<div class="impressum-hero__copy">
|
||||||
|
<p class="ui-kicker ui-kicker--wide">Rechtliche Angaben</p>
|
||||||
|
<h1>Impressum</h1>
|
||||||
|
<p class="impressum-hero__lead">
|
||||||
|
Diese Seite ist im Hoard-Design aufgebaut und mit Testdaten gefüllt. Vor produktivem
|
||||||
|
Einsatz bitte alle Angaben durch echte Unternehmensdaten ersetzen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="impressum-hero__meta">
|
||||||
|
<span class="ui-chip ui-chip--brand">
|
||||||
|
<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 class="impressum-hero__actions ui-action-row">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="details-grid">
|
||||||
|
<article class="detail-card ui-panel">
|
||||||
|
<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">
|
||||||
|
<div v-for="entry in companyDetails" :key="entry.label" class="detail-item">
|
||||||
|
<dt>{{ entry.label }}</dt>
|
||||||
|
<dd>{{ entry.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="detail-card ui-panel">
|
||||||
|
<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">
|
||||||
|
<div v-for="entry in contactDetails" :key="entry.label" class="detail-item">
|
||||||
|
<dt>{{ entry.label }}</dt>
|
||||||
|
<dd>
|
||||||
|
<a :href="entry.href">{{ entry.value }}</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="detail-card ui-panel">
|
||||||
|
<header class="detail-card__head">
|
||||||
|
<span class="ui-icon-tile"><v-icon icon="mdi-clipboard-text-outline" size="20" /></span>
|
||||||
|
<h2>Register & Steuer</h2>
|
||||||
|
</header>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<div v-for="entry in registerDetails" :key="entry.label" class="detail-item">
|
||||||
|
<dt>{{ entry.label }}</dt>
|
||||||
|
<dd>{{ entry.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="notes-section ui-panel">
|
||||||
|
<header class="ui-section-head">
|
||||||
|
<p class="ui-kicker">Rechtliche Hinweise</p>
|
||||||
|
<h2>Wichtige Zusatzinformationen</h2>
|
||||||
|
<p>Standardklauseln, die im Produktivbetrieb durch eine juristische Prüfung ersetzt werden sollten.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="notes-grid">
|
||||||
|
<article v-for="note in legalNotes" :key="note.title" class="note-card">
|
||||||
|
<span class="ui-icon-tile">
|
||||||
|
<v-icon :icon="note.icon" size="20" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3>{{ note.title }}</h3>
|
||||||
|
<p>{{ note.text }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.impressum-page {
|
||||||
|
--ui-page-width: 1180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Hero ---------- */
|
||||||
|
.impressum-hero {
|
||||||
|
--ui-gradient-angle: 130deg;
|
||||||
|
--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, 1.2fr) auto;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: end;
|
||||||
|
padding: var(--space-10) var(--space-8);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.impressum-hero__copy h1 {
|
||||||
|
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;
|
||||||
|
max-width: 64ch;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.impressum-hero__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.impressum-hero__actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Detail cards ---------- */
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-6);
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
padding-bottom: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-2xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Notes ---------- */
|
||||||
|
.notes-section {
|
||||||
|
padding: var(--space-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
display: grid;
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary-300) 40%, var(--color-border) 60%);
|
||||||
|
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 {
|
||||||
|
margin: 0;
|
||||||
|
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) {
|
||||||
|
.details-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 960px) {
|
||||||
|
.impressum-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: start;
|
||||||
|
padding: var(--space-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.impressum-hero__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid,
|
||||||
|
.notes-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-section {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.impressum-hero,
|
||||||
|
.notes-section,
|
||||||
|
.detail-card {
|
||||||
|
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 {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="login-page ui-page ui-page--centered">
|
||||||
|
<section class="login-shell ui-panel ui-panel-gradient ui-spotlight">
|
||||||
|
<aside class="login-brand">
|
||||||
|
<div class="login-brand__logo">
|
||||||
|
<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">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<ul class="login-points">
|
||||||
|
<li>
|
||||||
|
<span class="ui-icon-tile"><v-icon icon="mdi-folder-outline" size="18" /></span>
|
||||||
|
<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>
|
||||||
|
<span class="ui-icon-tile"><v-icon icon="mdi-language-markdown-outline" size="18" /></span>
|
||||||
|
<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>
|
||||||
|
<span class="ui-icon-tile"><v-icon icon="mdi-shield-check-outline" size="18" /></span>
|
||||||
|
<div>
|
||||||
|
<p class="login-points__title">Self-hosted & sicher</p>
|
||||||
|
<p class="login-points__text">Cookie-Auth, Rollenmodell, deine Infrastruktur.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<v-form class="login-form" @submit.prevent="handleSubmit">
|
||||||
|
<header class="login-form__head">
|
||||||
|
<p class="ui-kicker ui-kicker--xs">Login</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>
|
||||||
|
|
||||||
|
<p class="login-form__hint">
|
||||||
|
<v-icon icon="mdi-cookie-outline" size="14" />
|
||||||
|
Anmeldung erfolgt per sicherem Session-Cookie. Keine Tokens, keine Drittanbieter.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
variant="elevated"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
prepend-icon="mdi-arrow-right"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
:disabled="submitDisabled"
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn variant="text" block to="/welcome" prepend-icon="mdi-home-outline" size="small">
|
||||||
|
Zurück zur Startseite
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</section>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
--ui-centered-offset: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
max-width: 16ch;
|
||||||
|
font-size: clamp(2rem, 1.4rem + 1.5vw, 2.8rem);
|
||||||
|
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 {
|
||||||
|
margin: 0 0 var(--space-6);
|
||||||
|
max-width: 46ch;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-points {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-points li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
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);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-8);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__head p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__fields {
|
||||||
|
display: grid;
|
||||||
|
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) {
|
||||||
|
.login-shell {
|
||||||
|
display: block;
|
||||||
|
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 {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-intro {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-points li {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.login-form .v-btn) {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -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.'
|
||||||
@@ -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.'
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
Hoard – Page-Layout-Primitives
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.ui-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ui-page-gap, var(--space-6));
|
||||||
|
margin-inline: auto;
|
||||||
|
width: min(100%, var(--ui-page-width, 1180px));
|
||||||
|
padding-block:
|
||||||
|
var(--ui-page-padding-start, var(--space-5))
|
||||||
|
var(--ui-page-padding-end, var(--space-12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-page--centered {
|
||||||
|
width: 100%;
|
||||||
|
margin-inline: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - var(--ui-centered-offset, 220px));
|
||||||
|
padding: var(--ui-centered-padding, var(--space-10) var(--space-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-shell-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ui-shell-gap, var(--space-8));
|
||||||
|
width: min(100%, var(--ui-shell-width, 1080px));
|
||||||
|
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) {
|
||||||
|
.ui-page {
|
||||||
|
width: 100%;
|
||||||
|
gap: var(--ui-page-gap-mobile, var(--space-5));
|
||||||
|
padding-inline:
|
||||||
|
var(--ui-page-padding-inline-start-mobile, max(var(--space-3), env(safe-area-inset-left)))
|
||||||
|
var(--ui-page-padding-inline-end-mobile, max(var(--space-3), env(safe-area-inset-right)));
|
||||||
|
padding-block:
|
||||||
|
var(--ui-page-padding-start-mobile, var(--space-3))
|
||||||
|
var(--ui-page-padding-end-mobile, var(--space-8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-page--centered {
|
||||||
|
width: 100%;
|
||||||
|
min-height: calc(100vh - var(--ui-centered-offset-mobile, 200px));
|
||||||
|
padding: var(--ui-centered-padding-mobile, var(--space-6) var(--space-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-shell-grid {
|
||||||
|
width: 100%;
|
||||||
|
gap: var(--ui-shell-gap-mobile, var(--space-5));
|
||||||
|
padding:
|
||||||
|
var(--ui-shell-padding-block-mobile, var(--space-6))
|
||||||
|
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) {
|
||||||
|
.ui-page {
|
||||||
|
gap: var(--ui-page-gap-mobile-xs, var(--space-4));
|
||||||
|
padding-block:
|
||||||
|
var(--ui-page-padding-start-mobile-xs, var(--space-2))
|
||||||
|
var(--ui-page-padding-end-mobile-xs, var(--space-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-page--centered {
|
||||||
|
min-height: calc(100vh - var(--ui-centered-offset-mobile-xs, 180px));
|
||||||
|
padding: var(--ui-centered-padding-mobile-xs, var(--space-5) var(--space-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-shell-grid {
|
||||||
|
gap: var(--ui-shell-gap-mobile-xs, var(--space-4));
|
||||||
|
padding:
|
||||||
|
var(--ui-shell-padding-block-mobile-xs, var(--space-5))
|
||||||
|
var(--ui-shell-padding-inline-mobile-xs, var(--space-4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
Hoard – Surface- und Inhaltspattern
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.ui-kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-kicker::before {
|
||||||
|
content: '';
|
||||||
|
width: 18px;
|
||||||
|
height: 1px;
|
||||||
|
background-color: currentcolor;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-kicker--wide {
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-row--end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-panel-gradient {
|
||||||
|
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) {
|
||||||
|
.ui-action-row {
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-spotlight::before,
|
||||||
|
.ui-spotlight::after {
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
filter: blur(48px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.ui-kicker {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-action-row > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-section-head > h2 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
+358
@@ -0,0 +1,358 @@
|
|||||||
|
# Hoard – Style Guide
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
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 folgt drei Prinzipien:
|
||||||
|
|
||||||
|
1. **Dateien zuerst** – Inhalte, Namen, Strukturen und Aktionen sind optisch dominant, nicht die Chrome.
|
||||||
|
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, Status und Branding eingesetzt – nicht flächendeckend.
|
||||||
|
|
||||||
|
## Modernisierungs-Direktive
|
||||||
|
Beim Redesign gilt zusätzlich:
|
||||||
|
|
||||||
|
- **Hochwertige App-Shell:** Sidebar und Topbar dürfen sich „premium" anfühlen (klare Hierarchie, animierter Active-Indicator, gradient-tinged Brand-Bereich).
|
||||||
|
- **Layered Surfaces:** Statt einer einzigen Surface-Farbe gibt es eine kleine Surface-Hierarchie (`surface`, `surface-alt`, `surface-elevated`) mit subtil gestaffelten Schatten.
|
||||||
|
- **Microinteractions:** kurze, präzise Hover-/Active-/Focus-Animationen (160–240 ms). Keine wabernden, dauerhaften Bewegungen.
|
||||||
|
- **Ambient Gradients:** Hero-/Brand-Bereiche dürfen sehr weiche, breit gestreute Verläufe in Markenfarbe haben. Arbeitsflächen bleiben funktional und ruhig.
|
||||||
|
- **Typografische Präzision:** klare Größen-Hierarchie, ruhige Headlines, viel `letter-spacing` Disziplin, keine dekorativen Schriften.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
**Was weiterhin tabu ist:**
|
||||||
|
- kein Glassmorphism (echte Backdrop-Blur-Karten),
|
||||||
|
- kein Neon-/Gaming-Look,
|
||||||
|
- keine harten, durchgängigen Kontraste,
|
||||||
|
- keine Mehrfarbigkeit aus dem Akzent-Setup,
|
||||||
|
- keine dauerhaften Animationen oder Parallax-Spielereien.
|
||||||
|
|
||||||
|
## Visuelle Identität
|
||||||
|
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 ist **light-first**, Dark-Mode ist gleichwertig zu pflegen. Neue Komponenten beziehen Farben aus Design-Tokens.
|
||||||
|
|
||||||
|
## Farbpalette
|
||||||
|
|
||||||
|
### Primärfarben (Light)
|
||||||
|
- **Primary 800:** `#10421E` – tiefster Markenton, Texte auf Light-Surface.
|
||||||
|
- **Primary 700:** `#1C652F` – Primärbuttons, aktive Icons, Fokusrahmen.
|
||||||
|
- **Primary 600:** `#2E7D32` – Hover-Zustände, aktive Navigation.
|
||||||
|
- **Primary 500:** `#3C8F42` – ausgewählte Einträge, Badges, bestätigende States.
|
||||||
|
- **Primary 300:** `#A8D5A2` – weiche Hintergründe für Auswahlflächen.
|
||||||
|
- **Primary 100:** `#EAF5E8` – sehr subtile Hervorhebungen, Tints.
|
||||||
|
- **Primary 050:** `#F4FAF1` – fast unsichtbare Pflasterfläche für Hero-Glows.
|
||||||
|
|
||||||
|
### Akzent
|
||||||
|
- **Accent Lime:** `#B7E36B` – sparsam: Logo-Glow, kleine Highlights, Upload-Fortschritt.
|
||||||
|
|
||||||
|
### Neutrale (Light)
|
||||||
|
- **Background:** `#F5F8F2`
|
||||||
|
- **Background Tint:** `#EEF3EA` – ambient Hintergrund-Verlauf.
|
||||||
|
- **Surface:** `#FFFFFF` – Karten, Panels, Dialoge.
|
||||||
|
- **Surface Alt:** `#F1F4EE` – Toolbar, Tabellenkopf, Sekundärflächen.
|
||||||
|
- **Surface Elevated:** `#FBFCF8` – höhere Layer (Drawer-Inhalt, Dropdowns).
|
||||||
|
- **Border:** `#DCE3D6`
|
||||||
|
- **Border Strong:** `#C5CFBE`
|
||||||
|
- **Border Subtle:** `#E8EDE2` – innere Trennlinien.
|
||||||
|
|
||||||
|
### Text (Light)
|
||||||
|
- **Text Primary:** `#1A2A1E`
|
||||||
|
- **Text Secondary:** `#5A6A5E`
|
||||||
|
- **Text Muted:** `#7B897F`
|
||||||
|
- **Text On Primary:** `#FFFFFF`
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
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`
|
||||||
|
- **Warning:** `#B7791F`
|
||||||
|
- **Danger:** `#C0392B`
|
||||||
|
- **Info:** `#2F6FB3`
|
||||||
|
|
||||||
|
## Typografie
|
||||||
|
Neutral, gut lesbar, unauffällig modern – keine dekorativen Schriften.
|
||||||
|
|
||||||
|
**Stack:** `Inter, "Segoe UI", Roboto, system-ui, sans-serif`
|
||||||
|
|
||||||
|
**Skala (rem-orientiert, fixe px-Breakpoints, keine viewport-Skalierung):**
|
||||||
|
- `--font-size-2xs`: 11px – Status-Labels, Mini-Meta.
|
||||||
|
- `--font-size-xs`: 12px – Tabellenmeta, Helper-Text.
|
||||||
|
- `--font-size-sm`: 13px – sekundärer UI-Text.
|
||||||
|
- `--font-size-md`: 14px – Standard-UI-Text, Listen.
|
||||||
|
- `--font-size-lg`: 16px – akzentuierte UI-Hervorhebung.
|
||||||
|
- `--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.
|
||||||
|
|
||||||
|
**Gewichte:**
|
||||||
|
- 400 für Fließtext.
|
||||||
|
- 500 für UI-Text und Labels.
|
||||||
|
- 600 für Titel, Kicker, aktive Items.
|
||||||
|
- 700 nur sehr gezielt (Display-Headlines, Logo-Wortmarke).
|
||||||
|
|
||||||
|
**Letter-Spacing-Disziplin:**
|
||||||
|
- Display-Headlines: leicht negatives Tracking (`-0.01em` bis `-0.02em`).
|
||||||
|
- Kicker/Labels (uppercase): `0.05em` bis `0.08em`.
|
||||||
|
- Standard: `0`.
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
Modulare Skala in 4er-Schritten:
|
||||||
|
|
||||||
|
```
|
||||||
|
--space-1: 4px
|
||||||
|
--space-2: 8px
|
||||||
|
--space-3: 12px
|
||||||
|
--space-4: 16px
|
||||||
|
--space-5: 20px
|
||||||
|
--space-6: 24px
|
||||||
|
--space-7: 28px
|
||||||
|
--space-8: 32px
|
||||||
|
--space-10: 40px
|
||||||
|
--space-12: 48px
|
||||||
|
--space-16: 64px
|
||||||
|
```
|
||||||
|
|
||||||
|
Hauptseiten: `--ui-page-width` 1120–1200px, Padding orientiert sich an `--space-6`/`--space-8`.
|
||||||
|
|
||||||
|
## Formensprache (Border-Radien)
|
||||||
|
- `--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.
|
||||||
|
|
||||||
|
Keine pillenförmigen Vollflächen als Grundstil.
|
||||||
|
|
||||||
|
## Schatten & Tiefe
|
||||||
|
Mehrstufiges, sehr ruhiges Schatten-System:
|
||||||
|
|
||||||
|
```
|
||||||
|
--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);
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
- Höhe ca. 64 px, opaker `surface`-Hintergrund, dünne Bottom-Border.
|
||||||
|
- Branding links: Icon + Wortmarke + dezenter Subtext („Self-hosted Workspace").
|
||||||
|
- Page-Kontext (Name + Beschreibung) als sekundäre Info, nur ab `>1180px`.
|
||||||
|
- Rechts: Theme-Toggle, Account-Menü oder Login-Button.
|
||||||
|
- Subtile bottom shadow (`--shadow-xs`).
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
- Breite 268–284 px, Hintergrund `surface-alt`, leicht abgesetzt.
|
||||||
|
- Active-Indicator: linker, animierter, vertikaler Strich (`--color-primary-600`) + grüne Tint-Fläche + dunklerer Text.
|
||||||
|
- Hover: leichte Tint-Fläche, kein Scaling.
|
||||||
|
- Sektionen mit Kicker-Labels (`Navigation`, `Admin`).
|
||||||
|
- Mindesthöhe pro Item: 44 px (Desktop), 48 px (Mobile).
|
||||||
|
- Mobile = Bottom-Sheet-Drawer mit abgerundeten Top-Ecken.
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Komponenten
|
||||||
|
|
||||||
|
### Cards / Panels
|
||||||
|
- Hintergrund: `surface` mit subtilem 180°-Verlauf zu `surface-alt`.
|
||||||
|
- Border: `--color-border`.
|
||||||
|
- Radius: `--radius-lg`.
|
||||||
|
- Padding: `--space-6` (Standard), kleinere Inhalte `--space-4`.
|
||||||
|
- Hover (nur klar interaktive Cards): Border in Primary-300-Mix, `--shadow-md`, 2 px Y-Lift.
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
- **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.
|
||||||
|
- **Text/Tertiary:** für Zeilenaktionen, Toolbar, Nav. Hover: Surface-Tint.
|
||||||
|
- **Icon-Buttons:** mind. 40×40 (Desktop), 44×44 (Mobile).
|
||||||
|
- Letter-Spacing 0, kein Uppercase.
|
||||||
|
- Pro Bereich genau **eine Primäraktion**.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
- Outline-Variante, `surface` Hintergrund.
|
||||||
|
- Border `--color-border-strong`, Focus-Ring 3 px Primary-300-Tint + Primary-600-Border.
|
||||||
|
- Labels oberhalb (Vuetify-Outlined-Label), keine reinen Placeholder.
|
||||||
|
- Mind. 44 px Höhe auf Mobile.
|
||||||
|
|
||||||
|
### Listen / Tabellen
|
||||||
|
- Tabelle: `surface` Hintergrund, abgesetzter Tabellen-Header (`surface-alt`).
|
||||||
|
- Hover: sehr subtile Primary-Tint-Tönung.
|
||||||
|
- Selected Row: Primary-100 Hintergrund, `--color-primary-700` Text.
|
||||||
|
- Border-Bottom in `--color-border-subtle`.
|
||||||
|
- `ui-list-row` (Datei-/Item-Zeilen): luftiges Padding, klare Spalten, `transform: translateX(2px)` beim Hover.
|
||||||
|
|
||||||
|
### Status-Pills (`ui-status`)
|
||||||
|
- Kompakt, `--radius-xs`, mit dezentem Text/Background-Tint pro Status (Success/Info/Warning/Danger/Neutral).
|
||||||
|
- Optional kleines Punktindikator-Dot vor dem Text.
|
||||||
|
|
||||||
|
### Banner-Stack
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Hero-/Marketing-Bereiche
|
||||||
|
- Erlauben einen **ambient Verlauf** (`ui-panel-gradient` + Variablen).
|
||||||
|
- Optional dezenter „Spotlight"-Glow (radialer Verlauf, `--color-primary-300` mit niedriger Opazität, blur).
|
||||||
|
- Inhaltsbreite max ~64ch.
|
||||||
|
- Display-Headlines mit `--font-size-3xl` oder `--font-size-display` (nur Hero).
|
||||||
|
- Hero-Tags (`ui-chip`/`ui-tag`) als kompakte, dezent gerahmte Pills.
|
||||||
|
|
||||||
|
## Vorschau-Bereich (Files)
|
||||||
|
- **PDF-Vorschau:** neutraler Hintergrund, weiße „Papier"-Fläche mit `--shadow-md`, genug Rand. Controls minimal.
|
||||||
|
- **Bildvorschau:** dunklerer neutraler Viewer-Hintergrund nur, wenn das Bild dadurch besser wirkt; UI-Chrome konsistent.
|
||||||
|
- **Markdown-Editor/Reader:** wirkt wie ein Arbeitsdokument, nicht wie Blogpost. Lesbreite ~70ch, klare Heading-Hierarchie, sehr ruhige Codeblöcke.
|
||||||
|
|
||||||
|
## Empty-/Loading-States
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Responsive Verhalten
|
||||||
|
Desktop ist Hauptfokus, Mobile muss aber sauber funktionieren.
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
- `@media (width <= 1180px)` – Topbar-Kontextleiste verkürzen.
|
||||||
|
- `@media (width <= 960px)` – Sidebar wird Bottom-Drawer; Karten-/Grid-Bereiche werden einspaltig; Spacing wird reduziert.
|
||||||
|
- `@media (width <= 600px)` – CTAs full-width; Schriften minimal kompakter; Footer als Grid; Status-Pills behalten Lesbarkeit.
|
||||||
|
|
||||||
|
### Pflicht
|
||||||
|
- Desktop-Baseregeln nicht anfassen, nur Mobile-Overrides per Media Query.
|
||||||
|
- 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).
|
||||||
|
- Banner-Stack respektiert Safe-Area Bottom.
|
||||||
|
- Pflicht-Viewports im QA: `360x800`, `390x844`, `768x1024`, `1024x768`, `>=1280` – jeweils Light + Dark.
|
||||||
|
|
||||||
|
## Interaktionsprinzipien
|
||||||
|
- Primäraktionen klar sichtbar.
|
||||||
|
- Destruktives nie direkt neben Standardaktion.
|
||||||
|
- Dateizeilen sind klickbar, dürfen aber nicht wie Buttons aussehen.
|
||||||
|
- Hover, Active und Selected klar unterscheidbar (unterschiedliche Tints/Outlines).
|
||||||
|
- Fokus für Tastaturbedienung immer sichtbar (`outline: 2px solid var(--color-primary-500); outline-offset: 2px`).
|
||||||
|
|
||||||
|
## Designregeln für die Umsetzung
|
||||||
|
|
||||||
|
### Immer
|
||||||
|
- Tokens nutzen (Farben, Spacing, Radius, Shadow).
|
||||||
|
- Patterns wiederverwenden (`ui-panel`, `ui-page`, `ui-action-row`, `ui-kicker`, `ui-status`, `ui-chip`, `ui-spotlight`).
|
||||||
|
- Light- und Dark-Mode gleichwertig prüfen.
|
||||||
|
- Animationen kurz halten und `prefers-reduced-motion` respektieren.
|
||||||
|
|
||||||
|
### Vermeiden
|
||||||
|
- zu viele Karten ohne Funktion,
|
||||||
|
- zu viele Akzentfarben gleichzeitig,
|
||||||
|
- harte 1:1-Kontraste,
|
||||||
|
- echte Glasflächen / Backdrop-Blur,
|
||||||
|
- dauernde Bewegungen,
|
||||||
|
- Schriftgrößen, die mit der Viewport-Breite skalieren.
|
||||||
|
|
||||||
|
## Beispiel: Design-Tokens
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Surfaces */
|
||||||
|
--color-bg: #f5f8f2;
|
||||||
|
--color-bg-tint: #eef3ea;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-alt: #f1f4ee;
|
||||||
|
--color-surface-elevated: #fbfcf8;
|
||||||
|
|
||||||
|
--color-border: #dce3d6;
|
||||||
|
--color-border-strong: #c5cfbe;
|
||||||
|
--color-border-subtle: #e8ede2;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text: #1a2a1e;
|
||||||
|
--color-text-secondary: #5a6a5e;
|
||||||
|
--color-text-muted: #7b897f;
|
||||||
|
--color-text-on-primary: #ffffff;
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
--color-primary-800: #10421e;
|
||||||
|
--color-primary-700: #1c652f;
|
||||||
|
--color-primary-600: #2e7d32;
|
||||||
|
--color-primary-500: #3c8f42;
|
||||||
|
--color-primary-300: #a8d5a2;
|
||||||
|
--color-primary-100: #eaf5e8;
|
||||||
|
--color-primary-050: #f4faf1;
|
||||||
|
--color-accent-lime: #b7e36b;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--color-success: #2e7d32;
|
||||||
|
--color-warning: #b7791f;
|
||||||
|
--color-danger: #c0392b;
|
||||||
|
--color-info: #2f6fb3;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--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-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-7: 28px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kurzfassung als Design-Leitlinie
|
||||||
|
Wenn du bei einer UI-Entscheidung unsicher bist:
|
||||||
|
|
||||||
|
**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.**
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
"include": ["env.d.ts", "src/**/*.vue", "src/**/*.ts"],
|
||||||
"exclude": ["src/**/__tests__/*"],
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Extra safety for array and object lookups, but may have false positives.
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export default defineConfig({
|
|||||||
vue(),
|
vue(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
],
|
],
|
||||||
|
build: {
|
||||||
|
outDir: fileURLToPath(new URL('../API/wwwroot', import.meta.url)),
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
|||||||
@@ -1,2 +1,33 @@
|
|||||||
# Hoard
|
# Hoard
|
||||||
File and Knowledge Management
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./GUI/src/assets/images/icon.png" width="120" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Hoard ist eine einfache, selbst gehostete Web-App zur Verwaltung von Dateien und Ordnern – mit integrierter Markdown-Bearbeitung direkt im Browser.
|
||||||
|
|
||||||
|
Die Anwendung bewegt sich funktional zwischen Google Drive, Notion und Obsidian, bleibt dabei aber bewusst schlank und pragmatisch umgesetzt. Fokus liegt auf klarer Navigation, einfacher Bedienung und einem realistischen Umfang für ein Solo-Projekt.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 📁 Dateien und Ordner verwalten
|
||||||
|
- 🧭 Durch Ordnerstrukturen navigieren
|
||||||
|
- 📝 Markdown-Dateien direkt im Browser bearbeiten
|
||||||
|
- 🖼️ Vorschau für Bilder und PDFs
|
||||||
|
- 📦 Andere Dateien speichern und herunterladen
|
||||||
|
- 👥 Mehrbenutzerfähig (Accounts werden manuell erstellt)
|
||||||
|
- 🔐 Klassische Login-Session mit Cookies
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
- **Frontend:** Vue 3
|
||||||
|
- **Markdown-Editor:** md-editor-v3
|
||||||
|
- **Backend:** ASP.NET Core (C#)
|
||||||
|
- **Datenbank:** PostgreSQL
|
||||||
|
- **Dateispeicher:** MinIO (S3-kompatibel)
|
||||||
|
- **Authentifizierung:** Cookie-basiert
|
||||||
|
- **Deployment:** Self-hosted auf eigenem Server
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Ein bewusst einfach gehaltenes System, das sich wie eine klassische Dateiverwaltung im Browser anfühlt – ohne unnötige Komplexität, aber mit sauberer Grundlage für spätere Erweiterungen.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.6",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user