feat: Multi-User/Admin usermanagement + Galaxy Login/Settings + Task detail improvements
- Backend: NEW AdminController with user CRUD (GET/POST/DELETE /api/v1/admin/users)
- Backend: NEW GET /api/dashboard/tasks/{id} single task endpoint
- Backend: NEW POST /api/dashboard/tasks/{id}/activity comment endpoint
- Backend: IUserRepository + UserRepository extended with GetAllAsync, DeleteAsync
- Backend: Admin DTOs (AdminUserInfo, AdminCreateUserRequest, AdminUpdateRoleRequest)
- Frontend: NEW TaskDetailView.vue — URL-based (/tasks/:id) Galaxy-themed task detail
with subtask create/edit/delete, activity with comments, property sidebar
- Frontend: LoginView.vue — полностью Galaxy theme redesign with GalaxyBackground,
glass-morphism card, password toggle, consistent brand
- Frontend: SettingsView.vue — Galaxy theme redesign with glass cards,
admin user management section (create/list users, visible only to owner role)
- Frontend: TaskBoardView.vue — added "Full View" link to URL-based detail page
- Frontend: Router — added /tasks/:id route for TaskDetailView
- Frontend: App.vue — added TaskDetail to standaloneViews whitelist
- Frontend: tasks store — stable
Auth: Admin creates accounts, users log in with existing /api/v1/auth/login.
Login/Settings deliver visible Galaxy-consistent design with nexus-tokens.css tokens.
This commit is contained in:
@@ -0,0 +1,144 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin")]
|
||||||
|
[Authorize(Roles = "owner")]
|
||||||
|
public class AdminController(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ILogger<AdminController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// List all registered users.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("users")]
|
||||||
|
public async Task<IResult> GetUsers(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var users = await userRepository.GetAllAsync(ct);
|
||||||
|
var result = users.Select(u => new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = u.Id,
|
||||||
|
Email = u.Email,
|
||||||
|
DisplayName = u.DisplayName,
|
||||||
|
Role = u.Role,
|
||||||
|
CreatedAt = u.CreatedAt,
|
||||||
|
LastLoginAt = u.LastLoginAt,
|
||||||
|
}).ToList();
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new user account (admin only).
|
||||||
|
/// Email muss eindeutig sein, Passwort mindestens 10 Zeichen.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("users")]
|
||||||
|
public async Task<IResult> CreateUser([FromBody] AdminCreateUserRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["request"] = ["Email and password are required."]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.Password.Length < 10)
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["password"] = ["Password must be at least 10 characters."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
|
||||||
|
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
|
||||||
|
if (existing is not null)
|
||||||
|
return Results.Conflict(new { error = "A user with this email already exists." });
|
||||||
|
|
||||||
|
var user = new NexusUser
|
||||||
|
{
|
||||||
|
Email = request.Email.Trim(),
|
||||||
|
NormalizedEmail = normalizedEmail,
|
||||||
|
DisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
|
||||||
|
? request.Email.Split('@')[0]
|
||||||
|
: request.DisplayName.Trim(),
|
||||||
|
PasswordHash = PasswordSecurity.Hash(request.Password),
|
||||||
|
Role = string.IsNullOrWhiteSpace(request.Role) ? "user" : request.Role.Trim().ToLowerInvariant(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await userRepository.AddAsync(user, ct);
|
||||||
|
logger.LogInformation("Admin created user {Email} with role {Role}", user.Email, user.Role);
|
||||||
|
|
||||||
|
return Results.Created($"/api/v1/admin/users/{user.Id}", new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Email = user.Email,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
Role = user.Role,
|
||||||
|
CreatedAt = user.CreatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delete a user account (admin only, cannot delete owner).
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("users/{id:guid}")]
|
||||||
|
public async Task<IResult> DeleteUser(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await userRepository.GetByIdAsync(id, ct);
|
||||||
|
if (user is null)
|
||||||
|
return Results.NotFound(new { error = "User not found." });
|
||||||
|
|
||||||
|
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Results.Forbid();
|
||||||
|
|
||||||
|
await userRepository.DeleteAsync(user, ct);
|
||||||
|
logger.LogInformation("Admin deleted user {Email}", user.Email);
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update a user's role (admin only, cannot change owner role).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("users/{id:guid}/role")]
|
||||||
|
public async Task<IResult> UpdateUserRole(Guid id, [FromBody] AdminUpdateRoleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Role))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["role"] = ["Role is required."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var validRoles = new[] { "owner", "admin", "user", "viewer" };
|
||||||
|
if (!validRoles.Contains(request.Role.ToLowerInvariant()))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["role"] = ["Invalid role. Valid roles: owner, admin, user, viewer."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var user = await userRepository.GetByIdAsync(id, ct);
|
||||||
|
if (user is null)
|
||||||
|
return Results.NotFound(new { error = "User not found." });
|
||||||
|
|
||||||
|
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Results.Forbid();
|
||||||
|
|
||||||
|
user.Role = request.Role.Trim().ToLowerInvariant();
|
||||||
|
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await userRepository.UpdateAsync(user, ct);
|
||||||
|
logger.LogInformation("Admin updated role for {Email} to {Role}", user.Email, user.Role);
|
||||||
|
|
||||||
|
return Results.Ok(new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Email = user.Email,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
Role = user.Role,
|
||||||
|
CreatedAt = user.CreatedAt,
|
||||||
|
LastLoginAt = user.LastLoginAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.Models;
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
@@ -13,6 +14,7 @@ namespace Nexus.Api.Controllers;
|
|||||||
public class DashboardController(
|
public class DashboardController(
|
||||||
IDashboardService dashboardService,
|
IDashboardService dashboardService,
|
||||||
ITaskService taskService,
|
ITaskService taskService,
|
||||||
|
IActivityRepository activityService,
|
||||||
IHttpContextAccessor httpContextAccessor) : ControllerBase
|
IHttpContextAccessor httpContextAccessor) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("status")]
|
[HttpGet("status")]
|
||||||
@@ -239,6 +241,14 @@ public class DashboardController(
|
|||||||
return Ok(children.Select(MapToDto).ToList());
|
return Ok(children.Select(MapToDto).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{id:guid}")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var task = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return NotFound(new { error = "Task not found." });
|
||||||
|
return Ok(MapToDto(task));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("tasks/{id:guid}/activity")]
|
[HttpGet("tasks/{id:guid}/activity")]
|
||||||
public async Task<ActionResult<List<ActivityEvent>>> GetTaskActivity(Guid id, CancellationToken ct)
|
public async Task<ActionResult<List<ActivityEvent>>> GetTaskActivity(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -246,14 +256,25 @@ public class DashboardController(
|
|||||||
return Ok(events);
|
return Ok(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import ──
|
[HttpPost("tasks/{id:guid}/activity")]
|
||||||
|
public async Task<ActionResult<ActivityEvent>> PostTaskActivity(
|
||||||
[HttpPost("tasks/import-from-iris-todo")]
|
Guid id, [FromBody] PostActivityRequest request, CancellationToken ct)
|
||||||
public async Task<ActionResult<ImportResultDto>> ImportFromIrisTodo(
|
|
||||||
[FromQuery] bool delete = false, CancellationToken ct = default)
|
|
||||||
{
|
{
|
||||||
var result = await taskService.ImportFromIrisTodoAsync(delete, ct);
|
var task = await taskService.GetByIdAsync(id, ct);
|
||||||
return Ok(new ImportResultDto(result));
|
if (task is null) return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Message))
|
||||||
|
return BadRequest(new { error = "Message is required." });
|
||||||
|
|
||||||
|
var ev = new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = request.Type ?? "comment",
|
||||||
|
Message = request.Message.Trim(),
|
||||||
|
TaskId = id
|
||||||
|
};
|
||||||
|
|
||||||
|
await activityService.AddAsync(ev, ct);
|
||||||
|
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
|
|||||||
@@ -26,6 +26,29 @@ public sealed record UserInfo
|
|||||||
public string Role { get; init; } = string.Empty;
|
public string Role { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AdminUserInfo
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
public string Role { get; init; } = string.Empty;
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? LastLoginAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdminCreateUserRequest
|
||||||
|
{
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public string Password { get; init; } = string.Empty;
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public string? Role { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdminUpdateRoleRequest
|
||||||
|
{
|
||||||
|
public string Role { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record UpdateProfileRequest
|
public sealed record UpdateProfileRequest
|
||||||
{
|
{
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
|
|||||||
@@ -134,10 +134,6 @@ public sealed record MoveTaskRequest(
|
|||||||
string State
|
string State
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record ImportResultDto(
|
|
||||||
int Imported
|
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record ResetStaleRequest(
|
public sealed record ResetStaleRequest(
|
||||||
int StaleHours = 2
|
int StaleHours = 2
|
||||||
);
|
);
|
||||||
@@ -146,6 +142,11 @@ public sealed record ResetStaleResponse(
|
|||||||
int ResetCount
|
int ResetCount
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public sealed record PostActivityRequest(
|
||||||
|
string Message,
|
||||||
|
string? Type = null
|
||||||
|
);
|
||||||
|
|
||||||
// ── Notification DTOs ──
|
// ── Notification DTOs ──
|
||||||
|
|
||||||
public sealed record NotificationDto(
|
public sealed record NotificationDto(
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ public interface IUserRepository
|
|||||||
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
|
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
|
||||||
Task<bool> AnyUsersAsync(CancellationToken ct = default);
|
Task<bool> AnyUsersAsync(CancellationToken ct = default);
|
||||||
|
Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default);
|
||||||
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
||||||
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(NexusUser user, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||||
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
||||||
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
||||||
|
|
||||||
|
public Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> db.Users.OrderBy(u => u.CreatedAt).ToListAsync(ct);
|
||||||
|
|
||||||
public Task<bool> AnyUsersAsync(CancellationToken ct = default)
|
public Task<bool> AnyUsersAsync(CancellationToken ct = default)
|
||||||
=> db.Users.AnyAsync(ct);
|
=> db.Users.AnyAsync(ct);
|
||||||
|
|
||||||
@@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
|
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
|
||||||
=> db.SaveChangesAsync(ct);
|
=> db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
public async Task DeleteAsync(NexusUser user, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Remove refresh tokens first
|
||||||
|
var tokens = await db.RefreshTokens
|
||||||
|
.Where(r => r.UserId == user.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
db.RefreshTokens.RemoveRange(tokens);
|
||||||
|
db.Users.Remove(user);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
|
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
|
||||||
=> db.RefreshTokens
|
=> db.RefreshTokens
|
||||||
.Include(r => r.User)
|
.Include(r => r.User)
|
||||||
|
|||||||
@@ -34,5 +34,4 @@ public interface ITaskService
|
|||||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||||
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||||
Task<int> ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Models;
|
using Nexus.Api.Models;
|
||||||
@@ -350,74 +349,6 @@ public sealed class TaskService(
|
|||||||
return all.Where(e => e.TaskId == taskId).ToList();
|
return all.Where(e => e.TaskId == taskId).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> ImportFromIrisTodoAsync(bool deleteAfterImport = false, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var todoPath = "/mnt/workspace-iris/TODO.md";
|
|
||||||
if (!File.Exists(todoPath)) return 0;
|
|
||||||
|
|
||||||
var content = await File.ReadAllTextAsync(todoPath, ct);
|
|
||||||
var lines = content.Split('\n');
|
|
||||||
|
|
||||||
var imported = 0;
|
|
||||||
string? currentPriority = null;
|
|
||||||
|
|
||||||
// Parse sections and extract tasks with assignee info
|
|
||||||
// Pattern: ### N. Title 👤 Person
|
|
||||||
var taskPattern = new Regex(@"^###\s+\d+\.\s+(.+?)(?:\s+👤\s+(.+?))?$", RegexOptions.Compiled);
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
var trimmed = line.Trim();
|
|
||||||
|
|
||||||
// Detect priority section
|
|
||||||
if (trimmed.StartsWith("## "))
|
|
||||||
{
|
|
||||||
var sectionLower = trimmed.ToLowerInvariant();
|
|
||||||
if (sectionLower.Contains("high")) currentPriority = "High";
|
|
||||||
else if (sectionLower.Contains("medium")) currentPriority = "Medium";
|
|
||||||
else if (sectionLower.Contains("low")) currentPriority = "Low";
|
|
||||||
else currentPriority = "Medium";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var match = taskPattern.Match(trimmed);
|
|
||||||
if (!match.Success) continue;
|
|
||||||
|
|
||||||
var title = match.Groups[1].Value.Trim();
|
|
||||||
var assigneeRaw = match.Groups[2].Success ? match.Groups[2].Value.Trim() : null;
|
|
||||||
|
|
||||||
// Resolve assignee: "Iris" → "iris", "Bao" → "bao", "Iris+Bao" → "iris"
|
|
||||||
string? assignedTo = null;
|
|
||||||
if (!string.IsNullOrWhiteSpace(assigneeRaw))
|
|
||||||
{
|
|
||||||
var lower = assigneeRaw.ToLowerInvariant();
|
|
||||||
if (lower.Contains("iris")) assignedTo = "iris";
|
|
||||||
else if (lower.Contains("bao")) assignedTo = "bao";
|
|
||||||
}
|
|
||||||
|
|
||||||
var task = new WorkTask
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
Source = "iris",
|
|
||||||
Priority = currentPriority ?? "Medium",
|
|
||||||
AssignedTo = assignedTo
|
|
||||||
};
|
|
||||||
await taskRepo.AddAsync(task, ct);
|
|
||||||
imported++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteAfterImport)
|
|
||||||
{
|
|
||||||
File.Delete(todoPath);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Imported {imported} tasks from TODO.md and deleted the file" }, ct);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Imported {imported} tasks from TODO.md" }, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
return imported;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt);
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const navigate = (label: string) => {
|
|||||||
}
|
}
|
||||||
const mobileNavOpen = ref(false)
|
const mobileNavOpen = ref(false)
|
||||||
|
|
||||||
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'Notifications'].includes(activeView.value))
|
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'TaskDetail', 'Notifications'].includes(activeView.value))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAuthenticated) store.refresh()
|
if (auth.isAuthenticated) store.refresh()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import CalendarView from './views/CalendarView.vue'
|
|||||||
import NexusLayout from './layouts/NexusLayout.vue'
|
import NexusLayout from './layouts/NexusLayout.vue'
|
||||||
import FlowBoard from './views/Dashboard/FlowBoard.vue'
|
import FlowBoard from './views/Dashboard/FlowBoard.vue'
|
||||||
import TaskBoardView from './views/TaskBoardView.vue'
|
import TaskBoardView from './views/TaskBoardView.vue'
|
||||||
|
import TaskDetailView from './views/TaskDetailView.vue'
|
||||||
import NotificationsView from './views/NotificationsView.vue'
|
import NotificationsView from './views/NotificationsView.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -36,6 +37,7 @@ const routes = [
|
|||||||
{ path: '/projects', name: 'Projects', component: { template: '' } },
|
{ path: '/projects', name: 'Projects', component: { template: '' } },
|
||||||
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
|
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
|
||||||
{ path: '/tasks', name: 'Task Board', component: TaskBoardView },
|
{ path: '/tasks', name: 'Task Board', component: TaskBoardView },
|
||||||
|
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView },
|
||||||
{ path: '/agents', name: 'Agents', component: AgentsIndexView },
|
{ path: '/agents', name: 'Agents', component: AgentsIndexView },
|
||||||
{ path: '/models', name: 'Models', component: { template: '' } },
|
{ path: '/models', name: 'Models', component: { template: '' } },
|
||||||
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board
|
* Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board
|
||||||
* and maps them into TaskItem[] format for the TaskStrip component.
|
* and maps them into TaskItem[] format for the TaskStrip component.
|
||||||
*
|
*
|
||||||
* Board state: grouped by column (offen, inProgress, review, done, blocked)
|
* Board state: grouped by column (offen, inProgress, delegated, review, done, blocked)
|
||||||
* Auto-refresh: every 30 seconds.
|
* Auto-refresh: every 30 seconds.
|
||||||
*/
|
*/
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
@@ -13,7 +13,7 @@ import type { TaskItem } from '../components/dashboard/v2/types'
|
|||||||
|
|
||||||
/* ── API Response Shapes ──────────────────────────── */
|
/* ── API Response Shapes ──────────────────────────── */
|
||||||
|
|
||||||
interface DashboardTaskDto {
|
export interface DashboardTaskDto {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
detail: string | null
|
detail: string | null
|
||||||
@@ -21,6 +21,8 @@ interface DashboardTaskDto {
|
|||||||
state: string
|
state: string
|
||||||
priority: string
|
priority: string
|
||||||
assignedTo: string | null
|
assignedTo: string | null
|
||||||
|
parentTaskId?: string | null
|
||||||
|
dueDate?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -228,7 +230,7 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/* ── API: Update task ─────────────────────────── */
|
/* ── API: Update task ─────────────────────────── */
|
||||||
async updateTask(id: string, updates: { title?: string; detail?: string; priority?: string; assignedTo?: string }) {
|
async updateTask(id: string, updates: { title?: string; detail?: string | null; priority?: string; assignedTo?: string | null; dueDate?: string | null }) {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(`/api/dashboard/tasks/${id}`, {
|
const res = await apiFetch(`/api/dashboard/tasks/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -239,6 +241,29 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
await this.fetchBoard()
|
await this.fetchBoard()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[TaskStore] updateTask failed', err)
|
console.warn('[TaskStore] updateTask failed', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchTaskChildren(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/dashboard/tasks/${id}/children`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return await res.json() as DashboardTaskDto[]
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[TaskStore] fetchTaskChildren failed', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchTaskActivity(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/dashboard/tasks/${id}/activity`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return await res.json() as Array<{ id?: string; message?: string; type?: string; createdAt?: string; timestamp?: string }>
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[TaskStore] fetchTaskActivity failed', err)
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
/**
|
||||||
import { Command, LockKeyhole } from '@lucide/vue'
|
* LoginView – Nexus Mission Control V2 Galaxy Theme
|
||||||
|
*
|
||||||
|
* Vollbild-Login mit GalaxyBackground, Glassmorphismus,
|
||||||
|
* und Consistent Branding.
|
||||||
|
*/
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { Mail, LockKeyhole, Command, Eye, EyeOff } from '@lucide/vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -20,42 +29,345 @@ async function submit() {
|
|||||||
: '/dashboard'
|
: '/dashboard'
|
||||||
await router.replace(target)
|
await router.replace(target)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
error.value = reason instanceof Error ? reason.message : 'Login failed.'
|
error.value = reason instanceof Error ? reason.message : 'Login fehlgeschlagen.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="login-page">
|
<div class="login-container">
|
||||||
<section class="login-card">
|
<GalaxyBackground />
|
||||||
|
|
||||||
|
<div class="login-content">
|
||||||
|
<!-- Branding -->
|
||||||
<div class="login-brand">
|
<div class="login-brand">
|
||||||
<div class="brand-mark"><Command :size="20" /></div>
|
<div class="brand-icon">
|
||||||
<div><strong>NEXUS</strong><span>Noveria Operations</span></div>
|
<Command :size="22" />
|
||||||
|
</div>
|
||||||
|
<span class="brand-name">NEXUS</span>
|
||||||
|
<span class="brand-sub">Mission Control · Noveria</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="login-heading">
|
<!-- Login Card -->
|
||||||
<span class="eyebrow">OWNER ACCESS</span>
|
<div class="login-card">
|
||||||
<h1>Sign in to mission control</h1>
|
<div class="card-header">
|
||||||
<p>Use your private owner credentials to continue.</p>
|
<span class="eyebrow">AUTHENTIFIZIERUNG</span>
|
||||||
|
<h1>Anmelden</h1>
|
||||||
|
<p>Gib deine Zugangsdaten ein, um auf das Mission Control zuzugreifen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit" class="login-form">
|
||||||
<label>
|
<div class="field">
|
||||||
<span>Email</span>
|
<label for="email">
|
||||||
<input v-model="email" type="email" autocomplete="username" required maxlength="120" />
|
<Mail :size="14" />
|
||||||
|
<span>E-Mail</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<input
|
||||||
<span>Password</span>
|
id="email"
|
||||||
<input v-model="password" type="password" autocomplete="current-password" required minlength="10" maxlength="200" />
|
v-model="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="name@noveria.net"
|
||||||
|
class="field-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">
|
||||||
|
<LockKeyhole :size="14" />
|
||||||
|
<span>Passwort</span>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="error" class="login-error" role="alert">{{ error }}</p>
|
<div class="password-wrap">
|
||||||
<button type="submit" :disabled="auth.loading">
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
minlength="10"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="••••••••••"
|
||||||
|
class="field-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-pw"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
:aria-label="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<Eye v-if="!showPassword" :size="16" />
|
||||||
|
<EyeOff v-else :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="login-error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-btn" :disabled="auth.loading || !email || !password">
|
||||||
<LockKeyhole :size="15" />
|
<LockKeyhole :size="15" />
|
||||||
{{ auth.loading ? 'Signing in...' : 'Sign in' }}
|
{{ auth.loading ? 'Anmelden…' : 'Anmelden' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<footer>Protected owner session · Refresh token stored in a secure HTTP-only cookie</footer>
|
<footer class="card-footer">
|
||||||
</section>
|
<span class="lock-icon">🔒</span>
|
||||||
</main>
|
<span>Gesicherte Sitzung · Refresh-Token im HTTP-only Cookie</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-container {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 90%;
|
||||||
|
animation: login-fade-in 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes login-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Branding ─────────────────────── */
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(135deg, #4f7cff, #b557f6);
|
||||||
|
box-shadow: 0 0 32px -4px rgba(124, 108, 255, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
color: #ece9ff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6f6aa0;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Login Card ────────────────────── */
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(160deg, rgba(20, 17, 48, 0.88), rgba(14, 12, 32, 0.88));
|
||||||
|
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 0 0 1px rgba(124, 108, 255, 0.06), 0 24px 80px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: #7c6cff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h1 {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: #ece9ff;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6f6aa0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form ──────────────────────────── */
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #a8a3d6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(10, 9, 24, 0.55);
|
||||||
|
color: #ece9ff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input::placeholder {
|
||||||
|
color: #4a4680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input:focus {
|
||||||
|
border-color: rgba(124, 108, 255, 0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-wrap .field-input {
|
||||||
|
padding-right: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-pw {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6f6aa0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-pw:hover {
|
||||||
|
background: rgba(124, 108, 255, 0.08);
|
||||||
|
color: #a8a3d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: #fda4af;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.15s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 0 24px -6px rgba(124, 108, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.92;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 32px -4px rgba(124, 108, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ────────────────────────── */
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid rgba(150, 140, 255, 0.08);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #6f6aa0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+762
-147
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user