diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..9798fa4 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,41 @@ +stages: + - build + - test + - publish + +variables: + DOCKER_IMAGE: registry.leon-hoppe.de/leon.hoppe/spotiparty + +build: + stage: build + image: mcr.microsoft.com/dotnet/sdk:9.0 + script: + - dotnet restore + - dotnet build --configuration Release --no-restore + artifacts: + paths: + - "**/bin/Release" + expire_in: 10 minutes + +test: + stage: test + image: mcr.microsoft.com/dotnet/sdk:9.0 + script: + - dotnet test --verbosity normal + dependencies: + - build + +publish: + stage: publish + tags: + - docker + before_script: + - git lfs pull + script: + - export VERSION=$(echo $CI_COMMIT_TAG | sed 's/^v//') + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin registry.leon-hoppe.de + - docker build -t $DOCKER_IMAGE:$VERSION -t $DOCKER_IMAGE:latest -f SpotiParty.Web/Dockerfile . + - docker push $DOCKER_IMAGE:$VERSION + - docker push $DOCKER_IMAGE:latest + only: + - tags diff --git a/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml b/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml new file mode 100644 index 0000000..a1bee2d --- /dev/null +++ b/.idea/.idea.SpotiParty/.idea/dictionaries/project.xml @@ -0,0 +1,7 @@ + + + + Spoti + + + \ No newline at end of file diff --git a/.idea/.idea.SpotiParty/.idea/encodings.xml b/.idea/.idea.SpotiParty/.idea/encodings.xml index df87cf9..180f21a 100644 --- a/.idea/.idea.SpotiParty/.idea/encodings.xml +++ b/.idea/.idea.SpotiParty/.idea/encodings.xml @@ -1,4 +1,6 @@ - + + + \ No newline at end of file diff --git a/SpotiParty.AppHost/.aspire/settings.json b/SpotiParty.AppHost/.aspire/settings.json new file mode 100644 index 0000000..b74b76a --- /dev/null +++ b/SpotiParty.AppHost/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../SpotiParty.AppHost.csproj" +} \ No newline at end of file diff --git a/SpotiParty.AppHost/AppHost.cs b/SpotiParty.AppHost/AppHost.cs index d7cf9c6..af99f1e 100644 --- a/SpotiParty.AppHost/AppHost.cs +++ b/SpotiParty.AppHost/AppHost.cs @@ -2,6 +2,13 @@ using Projects; var builder = DistributedApplication.CreateBuilder(args); -var web = builder.AddProject("web"); +var dbServer = builder.AddPostgres("database") + .WithDataVolume(); + +var database = dbServer.AddDatabase("SpotiParty"); + +var web = builder.AddProject("web") + .WithReference(database) + .WaitForStart(database); builder.Build().Run(); \ No newline at end of file diff --git a/SpotiParty.AppHost/SpotiParty.AppHost.csproj b/SpotiParty.AppHost/SpotiParty.AppHost.csproj index d217efc..8007f83 100644 --- a/SpotiParty.AppHost/SpotiParty.AppHost.csproj +++ b/SpotiParty.AppHost/SpotiParty.AppHost.csproj @@ -4,7 +4,7 @@ Exe - net10.0 + net9.0 enable enable fe08e5bf-d119-470a-a4cc-7686f019ce64 @@ -12,6 +12,7 @@ + diff --git a/SpotiParty.Defaults/SpotiParty.Defaults.csproj b/SpotiParty.Defaults/SpotiParty.Defaults.csproj index 0f873c9..778990b 100644 --- a/SpotiParty.Defaults/SpotiParty.Defaults.csproj +++ b/SpotiParty.Defaults/SpotiParty.Defaults.csproj @@ -1,7 +1,7 @@ - net10.0 + net9.0 enable enable true diff --git a/SpotiParty.Web/Components/App.razor b/SpotiParty.Web/Components/App.razor index 8a2a405..1f62f7b 100644 --- a/SpotiParty.Web/Components/App.razor +++ b/SpotiParty.Web/Components/App.razor @@ -9,7 +9,7 @@ - + diff --git a/SpotiParty.Web/Components/Pages/CallbackPage.razor b/SpotiParty.Web/Components/Pages/CallbackPage.razor new file mode 100644 index 0000000..cad9f52 --- /dev/null +++ b/SpotiParty.Web/Components/Pages/CallbackPage.razor @@ -0,0 +1,27 @@ +@page "/callback" +@using SpotiParty.Web.Services +@inject NavigationManager Navigator +@inject AuthorizationHandler AuthHandler + +@code { + [Parameter, SupplyParameterFromQuery(Name = "error")] + public string? Error { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "code")] + public string? Code { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "state")] + public string? State { get; set; } + + protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + + if (string.IsNullOrWhiteSpace(Code)) { + Navigator.NavigateTo("/login", forceLoad: true); + return; + } + + await AuthHandler.HandleCallback(Code); + Navigator.NavigateTo("/admin", forceLoad: true); + } +} \ No newline at end of file diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor b/SpotiParty.Web/Components/Pages/EnqueuePage.razor index 4aec937..8c33bb7 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor @@ -1,15 +1,17 @@ -@page "/enqueue" +@page "/enqueue/{eventId}" @using SpotiParty.Web.Components.Components @rendermode InteractiveServer +SpotiParty +
-

🎵 SpotiParty

+

@(_event?.Name ?? " ")

Suche ein Lied und füge es zur Warteschlange hinzu

- +
@@ -27,7 +29,7 @@
-

SpotiParty © @_currentYear

+

SpotiParty © @_currentYear

diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs index 4033a6c..3ecd2d5 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs @@ -1,10 +1,18 @@ using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; using SpotifyAPI.Web; +using SpotiParty.Web.Models; using SpotiParty.Web.Services; namespace SpotiParty.Web.Components.Pages; -public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBase { +public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationManager navigator, DatabaseContext context) : ComponentBase { + + [Parameter] + public string EventId { get; set; } = string.Empty; + + private Event? _event; + private readonly int _currentYear = DateTime.Now.Year; private SpotifyClient _client = null!; @@ -17,7 +25,37 @@ public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBa protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - _client = await authHandler.ConfigureClient(); + + if (!Guid.TryParse(EventId, out var guid)) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + _event = await context.Events + .Include(e => e.Host) + .FirstOrDefaultAsync(e => e.Id == guid); + + if (_event is null) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + StateHasChanged(); + + var now = DateTime.Now; + if (_event.Start > now || _event.End < now) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + var client = await authHandler.ConfigureClient(_event.Host.UserId); + + if (client is null) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + _client = client; } private async Task ExecuteSearch() { @@ -51,9 +89,8 @@ public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBa private async Task DialogAccept() { if (_selectedTrack is null || _isAdding) return; _isAdding = true; - /*var request = new PlayerAddToQueueRequest(_selectedTrack.Uri); - await _client.Player.AddToQueue(request);*/ - await Task.Delay(3000); //TODO: Simulate adding + var request = new PlayerAddToQueueRequest(_selectedTrack.Uri); + await _client.Player.AddToQueue(request); _isAdding = false; _success = true; diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css index 5fa30c7..9125161 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css @@ -8,6 +8,7 @@ header h1 { margin: 0; font-size: 2rem; + height: 2rem; &:focus { outline: none; @@ -63,6 +64,10 @@ footer { text-align: center; padding: 0.5rem; background: var(--color-primary); + + a { + color: var(--color-text); + } } .confirm-dialog { @@ -105,16 +110,3 @@ footer { background: var(--color-background-light) !important; } -.button-secondary { - padding: 0.6rem 1rem; - border: none; - border-radius: 5px; - background: var(--color-accent); - color: var(--color-text); - cursor: pointer; -} - -.button-secondary:hover { - background: #555555; -} - diff --git a/SpotiParty.Web/Components/Pages/HomePage.razor b/SpotiParty.Web/Components/Pages/HomePage.razor new file mode 100644 index 0000000..4d45be9 --- /dev/null +++ b/SpotiParty.Web/Components/Pages/HomePage.razor @@ -0,0 +1,6 @@ +@page "/" +

HomePage

+ +@code { + +} \ No newline at end of file diff --git a/SpotiParty.Web/Components/Pages/HomePage.razor.css b/SpotiParty.Web/Components/Pages/HomePage.razor.css new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/SpotiParty.Web/Components/Pages/HomePage.razor.css @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/SpotiParty.Web/Components/Pages/LoginPage.razor b/SpotiParty.Web/Components/Pages/LoginPage.razor new file mode 100644 index 0000000..fa4a32c --- /dev/null +++ b/SpotiParty.Web/Components/Pages/LoginPage.razor @@ -0,0 +1,15 @@ +@page "/login" +@using SpotiParty.Web.Services + +@inject AuthorizationHandler AuthHandler +@inject NavigationManager Navigator + +@code { + + protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + var uri = await AuthHandler.ConstructLoginUri(); + Navigator.NavigateTo(uri.ToString(), forceLoad: true); + } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Components/Pages/NotFound.razor b/SpotiParty.Web/Components/Pages/NotFound.razor index 3a9526b..1ca63b4 100644 --- a/SpotiParty.Web/Components/Pages/NotFound.razor +++ b/SpotiParty.Web/Components/Pages/NotFound.razor @@ -6,7 +6,7 @@ protected override void OnInitialized() { base.OnInitialized(); - Navigator.NavigateTo("/enqueue", forceLoad: true); + Navigator.NavigateTo("/", forceLoad: true); } } diff --git a/SpotiParty.Web/Components/Routes.razor b/SpotiParty.Web/Components/Routes.razor index 95b320b..bdac4d7 100644 --- a/SpotiParty.Web/Components/Routes.razor +++ b/SpotiParty.Web/Components/Routes.razor @@ -1,4 +1,4 @@ - + diff --git a/SpotiParty.Web/DatabaseContext.cs b/SpotiParty.Web/DatabaseContext.cs new file mode 100644 index 0000000..831b9a5 --- /dev/null +++ b/SpotiParty.Web/DatabaseContext.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; +using SpotiParty.Web.Models; + +namespace SpotiParty.Web; + +public class DatabaseContext(DbContextOptions options) : DbContext(options) { + + public DbSet Users { get; set; } + + public DbSet Events { get; set; } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Dockerfile b/SpotiParty.Web/Dockerfile index 26ca620..460f58e 100644 --- a/SpotiParty.Web/Dockerfile +++ b/SpotiParty.Web/Dockerfile @@ -1,10 +1,10 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base USER $APP_UID WORKDIR /app EXPOSE 8080 EXPOSE 8081 -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["SpotiParty.Web/SpotiParty.Web.csproj", "SpotiParty.Web/"] diff --git a/SpotiParty.Web/Models/Event.cs b/SpotiParty.Web/Models/Event.cs new file mode 100644 index 0000000..41dc28c --- /dev/null +++ b/SpotiParty.Web/Models/Event.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace SpotiParty.Web.Models; + +public class Event { + [Key] + public Guid Id { get; init; } = Guid.CreateVersion7(); + + [ForeignKey("host")] + public virtual required User Host { get; set; } + + [MaxLength(255)] + public required string Name { get; set; } + + public DateTime Start { get; set; } = DateTime.Today; + + public DateTime End { get; set; } = DateTime.Today + TimeSpan.FromDays(1); +} \ No newline at end of file diff --git a/SpotiParty.Web/Models/User.cs b/SpotiParty.Web/Models/User.cs new file mode 100644 index 0000000..d17c980 --- /dev/null +++ b/SpotiParty.Web/Models/User.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace SpotiParty.Web.Models; + +public class User { + [Key] + public Guid UserId { get; init; } = Guid.CreateVersion7(); + + [MaxLength(255)] + public required string SpotifyUserId { get; init; } + + [MaxLength(255)] + public required string DisplayName { get; init; } + + [MaxLength(255)] + public required string RefreshToken { get; set; } + + public bool IsAdmin { get; set; } +} \ No newline at end of file diff --git a/SpotiParty.Web/Program.cs b/SpotiParty.Web/Program.cs index 3d59ae1..53c8539 100644 --- a/SpotiParty.Web/Program.cs +++ b/SpotiParty.Web/Program.cs @@ -1,15 +1,76 @@ +using HopFrame.Core.Services; +using HopFrame.Web; +using Microsoft.EntityFrameworkCore; +using SpotiParty.Web; using SpotiParty.Web.Components; +using SpotiParty.Web.Models; using SpotiParty.Web.Services; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddEnvironmentVariables(); + // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); builder.AddServiceDefaults(); -builder.Services.AddSingleton(); +AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); +AppContext.SetSwitch("Npgsql.DisableDateTimeInfinityConversions", true); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.AddNpgsqlDbContext("SpotiParty"); +builder.Services.AddDbContextFactory(options => { + options.UseNpgsql(builder.Configuration.GetConnectionString("SpotiParty")); +}); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHopFrame(config => { + config.SetLoginPage("/login"); + + config.AddDbContext(context => { + context.Table(table => { + table + .SetViewPolicy(DashboardAuthHandler.AdminPolicy) + .SetCreatePolicy(DashboardAuthHandler.AdminPolicy) + .SetUpdatePolicy(DashboardAuthHandler.AdminPolicy) + .SetDeletePolicy(DashboardAuthHandler.AdminPolicy); + + table.Property(u => u.RefreshToken) + .List(false) + .DisplayValue(false) + .SetEditable(false); + }); + + context.Table() + .SetDisplayName(Guid.NewGuid().ToString()) + .Ignore(true); + }); + + config.AddCustomRepository(e => e.Id, table => { + table.SetDisplayName("Events"); + + table.Property(e => e.Id) + .List(false) + .SetEditable(false) + .SetCreatable(false); + + table.Property(e => e.Host) + .List(false) + .SetEditable(false) + .SetCreatable(false) + .SetDisplayedProperty(u => u.DisplayName); + + table.ShowSearchSuggestions(false); + }); + + config.AddPlugin(); +}); var app = builder.Build(); @@ -20,15 +81,21 @@ if (!app.Environment.IsDevelopment()) { app.UseHsts(); } +await using (var scope = app.Services.CreateAsyncScope()) { + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); +} + app.MapDefaultEndpoints(); -app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); -app.UseHttpsRedirection(); +app.UseStatusCodePagesWithReExecute("/not-found"); +//app.UseHttpsRedirection(); app.UseAntiforgery(); app.MapStaticAssets(); app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .AddHopFramePages(); app.Run(); \ No newline at end of file diff --git a/SpotiParty.Web/Services/AdminDashboardPlugin.cs b/SpotiParty.Web/Services/AdminDashboardPlugin.cs new file mode 100644 index 0000000..f750b52 --- /dev/null +++ b/SpotiParty.Web/Services/AdminDashboardPlugin.cs @@ -0,0 +1,27 @@ +using HopFrame.Core.Config; +using HopFrame.Web.Plugins.Events; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components; +using SpotiParty.Web.Models; + +namespace SpotiParty.Web.Services; + +public class AdminDashboardPlugin(NavigationManager navigator) { + + [HopFrame.Web.Plugins.Annotations.EventHandler] + public void OnTableInitialized(TableInitializedEvent e) { + if (e.Table.TableType != typeof(Event)) return; + + e.AddEntityButton(new IconInfo { + Variant = IconVariant.Regular, + Size = IconSize.Size16, + Name = "Open" + }, OnButtonClicked); + } + + private void OnButtonClicked(object o, TableConfig config) { + var entity = (Event)o; + navigator.NavigateTo(navigator.BaseUri + $"enqueue/{entity.Id}"); + } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Services/AuthorizationHandler.cs b/SpotiParty.Web/Services/AuthorizationHandler.cs index 1127112..8ef99f4 100644 --- a/SpotiParty.Web/Services/AuthorizationHandler.cs +++ b/SpotiParty.Web/Services/AuthorizationHandler.cs @@ -1,46 +1,76 @@ -using System.Net.Http.Headers; -using System.Security.Authentication; -using System.Text; -using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; using SpotifyAPI.Web; +using SpotiParty.Web.Models; namespace SpotiParty.Web.Services; -public sealed class AuthorizationHandler { +public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseContext context, ClientSideStorage storage, IConfiguration configuration) { - private AuthResponse? _token; - - private class AuthResponse { - [JsonPropertyName("access_token")] - public string AccessToken { get; set; } - - [JsonPropertyName("token_type")] - public string TokenType { get; set; } - - [JsonPropertyName("expires_in")] - public int ExpiresIn { get; set; } + private async Task<(string clientId, string clientSecret)> GetClientSecrets() { + #if DEBUG + var fileLines = await File.ReadAllLinesAsync(Path.Combine(Environment.CurrentDirectory, ".dev-token")); + return (fileLines[0], fileLines[1]); + #endif + +#pragma warning disable CS0162 // Unreachable code detected + return (configuration["ClientId"]!, configuration["ClientSecret"]!); +#pragma warning restore CS0162 // Unreachable code detected } - public async Task ConfigureClient() { - if (_token is null) { - var fileLines = await File.ReadAllLinesAsync(Path.Combine(Environment.CurrentDirectory, ".dev-token")); - var clientId = fileLines[0]; - var clientSecret = fileLines[1]; + public async Task ConfigureClient(Guid userId) { + var user = await context.Users.FindAsync(userId); + if (user is null) return null; + + var (clientId, clientSecret) = await GetClientSecrets(); + var request = new AuthorizationCodeRefreshRequest(clientId, clientSecret, user.RefreshToken); + var response = await new OAuthClient().RequestToken(request); + + return new SpotifyClient(response.AccessToken); + } - var basicAuthToken = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}")); - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse($"Basic {basicAuthToken}"); + public async Task ConstructLoginUri() { + var (clientId, _) = await GetClientSecrets(); + var request = new LoginRequest( + new Uri(navigator.BaseUri + "callback"), + clientId, + LoginRequest.ResponseType.Code) { + Scope = [Scopes.UserReadPlaybackState, Scopes.UserModifyPlaybackState, Scopes.UserReadPrivate, Scopes.UserReadEmail] + }; - var response = await httpClient.PostAsync("https://accounts.spotify.com/api/token", new FormUrlEncodedContent(new[] { - new KeyValuePair("grant_type", "client_credentials") - })); - response.EnsureSuccessStatusCode(); - _token = await response.Content.ReadFromJsonAsync(); + return request.ToUri(); + } - if (_token is null) throw new AuthenticationException("Spotify auth failed!"); + public async Task HandleCallback(string code) { + var (clientId, clientSecret) = await GetClientSecrets(); + var response = await new OAuthClient().RequestToken( + new AuthorizationCodeTokenRequest( + clientId, + clientSecret, + code, + new Uri(navigator.BaseUri + "callback"))); + + var client = new SpotifyClient(response.AccessToken); + var spotiUser = await client.UserProfile.Current(); + + var user = await context.Users.FirstOrDefaultAsync(u => u.SpotifyUserId == spotiUser.Id); + if (user is null) { + user = new User { + DisplayName = spotiUser.DisplayName, + RefreshToken = response.RefreshToken, + SpotifyUserId = spotiUser.Id, + IsAdmin = await context.Users.CountAsync() == 0 + }; + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); } - - return new SpotifyClient(_token.AccessToken, _token.TokenType); + else { + user.RefreshToken = response.RefreshToken; + await context.SaveChangesAsync(); + } + + storage.SaveUserToken(response.RefreshToken); } } \ No newline at end of file diff --git a/SpotiParty.Web/Services/ClientSideStorage.cs b/SpotiParty.Web/Services/ClientSideStorage.cs new file mode 100644 index 0000000..71d6297 --- /dev/null +++ b/SpotiParty.Web/Services/ClientSideStorage.cs @@ -0,0 +1,19 @@ +namespace SpotiParty.Web.Services; + +public class ClientSideStorage(IHttpContextAccessor accessor) { + + private const string AuthCookieName = "RefreshToken"; + + public void SaveUserToken(string token) { + accessor.HttpContext?.Response.Cookies.Append(AuthCookieName, token); + } + + public string? GetUserToken() { + return accessor.HttpContext?.Request.Cookies[AuthCookieName]; + } + + public void DeleteUserToken() { + accessor.HttpContext?.Response.Cookies.Delete(AuthCookieName); + } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Services/DashboardAuthHandler.cs b/SpotiParty.Web/Services/DashboardAuthHandler.cs new file mode 100644 index 0000000..ced6f4c --- /dev/null +++ b/SpotiParty.Web/Services/DashboardAuthHandler.cs @@ -0,0 +1,44 @@ +using HopFrame.Core.Services; +using Microsoft.EntityFrameworkCore; +using SpotiParty.Web.Models; + +namespace SpotiParty.Web.Services; + +public class DashboardAuthHandler(ClientSideStorage storage, IDbContextFactory contextFactory) : IHopFrameAuthHandler { + + public const string AdminPolicy = "ADMIN"; + + public async Task IsAuthenticatedAsync(string? policy) { + var user = await GetCurrentUser(); + if (user is null) + return false; + + if (policy == AdminPolicy) { + return user.IsAdmin; + } + + return true; + } + + public async Task GetCurrentUserDisplayNameAsync() { + var token = storage.GetUserToken(); + if (string.IsNullOrWhiteSpace(token)) + return string.Empty; + + await using var context = await contextFactory.CreateDbContextAsync(); + var user = await context.Users.AsNoTracking().FirstOrDefaultAsync(u => u.RefreshToken == token); + if (user is null) return string.Empty; + + return user.DisplayName; + } + + public async Task GetCurrentUser() { + var token = storage.GetUserToken(); + if (string.IsNullOrWhiteSpace(token)) + return null; + + await using var context = await contextFactory.CreateDbContextAsync(); + return await context.Users.AsNoTracking().FirstOrDefaultAsync(u => u.RefreshToken == token); + } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Services/EventsDashboardRepo.cs b/SpotiParty.Web/Services/EventsDashboardRepo.cs new file mode 100644 index 0000000..496b58e --- /dev/null +++ b/SpotiParty.Web/Services/EventsDashboardRepo.cs @@ -0,0 +1,53 @@ +using HopFrame.Core.Repositories; +using Microsoft.EntityFrameworkCore; +using SpotiParty.Web.Models; + +namespace SpotiParty.Web.Services; + +public class EventsDashboardRepo(DatabaseContext context, DashboardAuthHandler handler) : IHopFrameRepository { + public async Task> LoadPage(int page, int perPage) { + var user = await handler.GetCurrentUser(); + if (user is null) return []; + + return await context.Events + .AsNoTracking() + .Include(e => e.Host) + .Where(e => e.Host.UserId == user.UserId) + .Skip(page * perPage) + .Take(perPage) + .ToListAsync(); + } + + public async Task> Search(string searchTerm, int page, int perPage) { + var entries = await LoadPage(page, perPage); + return new(entries, await GetTotalPageCount(perPage)); + } + + public async Task GetTotalPageCount(int perPage) { + double count = await context.Events.CountAsync(); + return Convert.ToInt32(Math.Ceiling(count / perPage)); + } + + public async Task CreateItem(Event item) { + var creator = await handler.GetCurrentUser(); + context.Attach(creator!); + item.Host = creator!; + + await context.Events.AddAsync(item); + await context.SaveChangesAsync(); + } + + public async Task EditItem(Event item) { + context.Events.Update(item); + await context.SaveChangesAsync(); + } + + public async Task DeleteItem(Event item) { + context.Events.Remove(item); + await context.SaveChangesAsync(); + } + + public async Task GetOne(Guid key) { + return await context.Events.FindAsync(key); + } +} \ No newline at end of file diff --git a/SpotiParty.Web/SpotiParty.Web.csproj b/SpotiParty.Web/SpotiParty.Web.csproj index b092286..a24811a 100644 --- a/SpotiParty.Web/SpotiParty.Web.csproj +++ b/SpotiParty.Web/SpotiParty.Web.csproj @@ -1,7 +1,7 @@ - net10.0 + net9.0 enable enable true @@ -19,6 +19,13 @@
+ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -75,4 +82,8 @@ + + + + diff --git a/SpotiParty.Web/appsettings.Development.json b/SpotiParty.Web/appsettings.Development.json index f8063ef..f120a01 100644 --- a/SpotiParty.Web/appsettings.Development.json +++ b/SpotiParty.Web/appsettings.Development.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Microsoft.AspNetCore": "Warning", + "HopFrame": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" } }, "DetailedErrors": true diff --git a/SpotiParty.Web/appsettings.json b/SpotiParty.Web/appsettings.json index 10f68b8..d268177 100644 --- a/SpotiParty.Web/appsettings.json +++ b/SpotiParty.Web/appsettings.json @@ -5,5 +5,7 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ClientId": null, + "ClientSecret": null } diff --git a/SpotiParty.Web/wwwroot/app.css b/SpotiParty.Web/wwwroot/app.css index 08d4626..c5ea085 100644 --- a/SpotiParty.Web/wwwroot/app.css +++ b/SpotiParty.Web/wwwroot/app.css @@ -2,6 +2,7 @@ --color-primary: #1db954; --color-primary-dark: #17a74a; --color-accent: #333333; + --color-accent-dark: #555555; --color-background: #121212; --color-background-light: #181818; --color-text: #ffffff; @@ -38,3 +39,20 @@ body { background: var(--color-background); } } + +.button-secondary { + padding: 0.6rem 1rem; + border: none; + border-radius: 5px; + background: var(--color-accent); + color: var(--color-text); + cursor: pointer; + + &:hover { + background: var(--color-accent-dark); + } + + &:disabled { + background: var(--color-background); + } +} diff --git a/SpotiParty.Web/wwwroot/favicon.ico b/SpotiParty.Web/wwwroot/favicon.ico new file mode 100644 index 0000000..402e5c8 Binary files /dev/null and b/SpotiParty.Web/wwwroot/favicon.ico differ diff --git a/SpotiParty.Web/wwwroot/favicon.png b/SpotiParty.Web/wwwroot/favicon.png deleted file mode 100644 index 8422b59..0000000 Binary files a/SpotiParty.Web/wwwroot/favicon.png and /dev/null differ diff --git a/SpotiParty.sln.DotSettings b/SpotiParty.sln.DotSettings new file mode 100644 index 0000000..436523d --- /dev/null +++ b/SpotiParty.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/SpotiParty.sln.DotSettings.user b/SpotiParty.sln.DotSettings.user new file mode 100644 index 0000000..fe55006 --- /dev/null +++ b/SpotiParty.sln.DotSettings.user @@ -0,0 +1,10 @@ + + True + True + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file