From 825bd80ef0af837dc9f1b801e76978bb95f2b689 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 30 Nov 2025 15:19:49 +0100 Subject: [PATCH 1/4] Implemented Login workflow --- .../.idea/dictionaries/project.xml | 7 ++ .idea/.idea.SpotiParty/.idea/encodings.xml | 4 +- SpotiParty.AppHost/.aspire/settings.json | 3 + SpotiParty.AppHost/AppHost.cs | 9 ++- SpotiParty.AppHost/SpotiParty.AppHost.csproj | 1 + .../Components/Pages/CallbackPage.razor | 27 +++++++ .../Components/Pages/EnqueuePage.razor | 2 +- .../Components/Pages/EnqueuePage.razor.cs | 26 +++++-- .../Components/Pages/EnqueuePage.razor.css | 13 ---- .../Components/Pages/LoginPage.razor | 17 +++++ .../Components/Pages/NotFound.razor | 2 +- SpotiParty.Web/DatabaseContext.cs | 10 +++ .../20251130141048_Initial.Designer.cs | 51 +++++++++++++ .../Migrations/20251130141048_Initial.cs | 35 +++++++++ .../DatabaseContextModelSnapshot.cs | 48 ++++++++++++ SpotiParty.Web/Models/User.cs | 14 ++++ SpotiParty.Web/Program.cs | 12 ++- .../Services/AuthorizationHandler.cs | 75 +++++++++++-------- SpotiParty.Web/SpotiParty.Web.csproj | 6 ++ SpotiParty.Web/wwwroot/app.css | 18 +++++ SpotiParty.sln.DotSettings | 2 + SpotiParty.sln.DotSettings.user | 4 + 22 files changed, 330 insertions(+), 56 deletions(-) create mode 100644 .idea/.idea.SpotiParty/.idea/dictionaries/project.xml create mode 100644 SpotiParty.AppHost/.aspire/settings.json create mode 100644 SpotiParty.Web/Components/Pages/CallbackPage.razor create mode 100644 SpotiParty.Web/Components/Pages/LoginPage.razor create mode 100644 SpotiParty.Web/DatabaseContext.cs create mode 100644 SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs create mode 100644 SpotiParty.Web/Migrations/20251130141048_Initial.cs create mode 100644 SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs create mode 100644 SpotiParty.Web/Models/User.cs create mode 100644 SpotiParty.sln.DotSettings create mode 100644 SpotiParty.sln.DotSettings.user 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..180f365 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("database-data"); + +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..6b1b894 100644 --- a/SpotiParty.AppHost/SpotiParty.AppHost.csproj +++ b/SpotiParty.AppHost/SpotiParty.AppHost.csproj @@ -12,6 +12,7 @@ + diff --git a/SpotiParty.Web/Components/Pages/CallbackPage.razor b/SpotiParty.Web/Components/Pages/CallbackPage.razor new file mode 100644 index 0000000..515e0b0 --- /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("/enqueue", 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..5a95e06 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor @@ -1,4 +1,4 @@ -@page "/enqueue" +@page "/enqueue/{userid}" @using SpotiParty.Web.Components.Components @rendermode InteractiveServer diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs index 4033a6c..5d7b76d 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs @@ -4,7 +4,11 @@ using SpotiParty.Web.Services; namespace SpotiParty.Web.Components.Pages; -public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBase { +public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationManager navigator) : ComponentBase { + + [Parameter] + public string UserId { get; set; } = string.Empty; + private readonly int _currentYear = DateTime.Now.Year; private SpotifyClient _client = null!; @@ -17,7 +21,20 @@ public partial class EnqueuePage(AuthorizationHandler authHandler) : ComponentBa protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - _client = await authHandler.ConfigureClient(); + + if (!Guid.TryParse(UserId, out var guid)) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + var client = await authHandler.ConfigureClient(guid); + + if (client is null) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + _client = client; } private async Task ExecuteSearch() { @@ -51,9 +68,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..3a1ded2 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css @@ -105,16 +105,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/LoginPage.razor b/SpotiParty.Web/Components/Pages/LoginPage.razor new file mode 100644 index 0000000..31adca1 --- /dev/null +++ b/SpotiParty.Web/Components/Pages/LoginPage.razor @@ -0,0 +1,17 @@ +@page "/login" +@using SpotiParty.Web.Services + +Mit Spotify einloggen + +@inject AuthorizationHandler AuthHandler + +@code { + + private Uri _uri = null!; + + protected override async Task OnInitializedAsync() { + await base.OnInitializedAsync(); + _uri = await AuthHandler.ConstructLoginUri(); + } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Components/Pages/NotFound.razor b/SpotiParty.Web/Components/Pages/NotFound.razor index 3a9526b..76193cc 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("/login", forceLoad: true); } } diff --git a/SpotiParty.Web/DatabaseContext.cs b/SpotiParty.Web/DatabaseContext.cs new file mode 100644 index 0000000..1887175 --- /dev/null +++ b/SpotiParty.Web/DatabaseContext.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore; +using SpotiParty.Web.Models; + +namespace SpotiParty.Web; + +public class DatabaseContext(DbContextOptions options) : DbContext(options) { + + public DbSet Users { get; set; } + +} \ No newline at end of file diff --git a/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs b/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs new file mode 100644 index 0000000..a6212ea --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs @@ -0,0 +1,51 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpotiParty.Web; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20251130141048_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpotiParty.Web.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SpotiParty.Web/Migrations/20251130141048_Initial.cs b/SpotiParty.Web/Migrations/20251130141048_Initial.cs new file mode 100644 index 0000000..e17be45 --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130141048_Initial.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + DisplayName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + RefreshToken = table.Column(type: "character varying(255)", maxLength: 255, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.UserId); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs new file mode 100644 index 0000000..715d899 --- /dev/null +++ b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs @@ -0,0 +1,48 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpotiParty.Web; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + [DbContext(typeof(DatabaseContext))] + partial class DatabaseContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpotiParty.Web.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SpotiParty.Web/Models/User.cs b/SpotiParty.Web/Models/User.cs new file mode 100644 index 0000000..2e4da79 --- /dev/null +++ b/SpotiParty.Web/Models/User.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace SpotiParty.Web.Models; + +public class User { + [Key] + public Guid UserId { get; init; } = Guid.CreateVersion7(); + + [MaxLength(255)] + public string DisplayName { get; init; } + + [MaxLength(255)] + public string RefreshToken { get; set; } +} \ No newline at end of file diff --git a/SpotiParty.Web/Program.cs b/SpotiParty.Web/Program.cs index 3d59ae1..f0a16a0 100644 --- a/SpotiParty.Web/Program.cs +++ b/SpotiParty.Web/Program.cs @@ -1,3 +1,5 @@ +using Microsoft.EntityFrameworkCore; +using SpotiParty.Web; using SpotiParty.Web.Components; using SpotiParty.Web.Services; @@ -9,7 +11,8 @@ builder.Services.AddRazorComponents() builder.AddServiceDefaults(); -builder.Services.AddSingleton(); +builder.AddNpgsqlDbContext("SpotiParty"); +builder.Services.AddScoped(); var app = builder.Build(); @@ -20,10 +23,15 @@ 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.UseHttpsRedirection(); app.UseAntiforgery(); diff --git a/SpotiParty.Web/Services/AuthorizationHandler.cs b/SpotiParty.Web/Services/AuthorizationHandler.cs index 1127112..1cb8e80 100644 --- a/SpotiParty.Web/Services/AuthorizationHandler.cs +++ b/SpotiParty.Web/Services/AuthorizationHandler.cs @@ -1,46 +1,57 @@ -using System.Net.Http.Headers; -using System.Security.Authentication; -using System.Text; -using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Components; using SpotifyAPI.Web; +using SpotiParty.Web.Models; namespace SpotiParty.Web.Services; -public sealed class AuthorizationHandler { +public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseContext context) { - 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() { + var fileLines = await File.ReadAllLinesAsync(Path.Combine(Environment.CurrentDirectory, ".dev-token")); + return (fileLines[0], fileLines[1]); } - 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"))); - return new SpotifyClient(_token.AccessToken, _token.TokenType); + var client = new SpotifyClient(response.AccessToken); + var spotiUser = await client.UserProfile.Current(); + var user = new User { + DisplayName = spotiUser.DisplayName, + RefreshToken = response.RefreshToken + }; + + await context.Users.AddAsync(user); + await context.SaveChangesAsync(); } } \ No newline at end of file diff --git a/SpotiParty.Web/SpotiParty.Web.csproj b/SpotiParty.Web/SpotiParty.Web.csproj index b092286..2e2e953 100644 --- a/SpotiParty.Web/SpotiParty.Web.csproj +++ b/SpotiParty.Web/SpotiParty.Web.csproj @@ -19,6 +19,12 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + 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.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..8696f24 --- /dev/null +++ b/SpotiParty.sln.DotSettings.user @@ -0,0 +1,4 @@ + + ForceIncluded + ForceIncluded + ForceIncluded \ No newline at end of file -- 2.49.1 From 5d1fc1f347aa3a93da9f347beeaa89aaa39c1b37 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 30 Nov 2025 19:01:38 +0100 Subject: [PATCH 2/4] Added hopframe backend --- .../Components/Pages/CallbackPage.razor | 2 +- .../Components/Pages/EnqueuePage.razor | 2 +- .../Components/Pages/LoginPage.razor | 8 +- SpotiParty.Web/DatabaseContext.cs | 2 + ...0251130142549_Added Spotify Id.Designer.cs | 56 +++++++++++ .../20251130142549_Added Spotify Id.cs | 30 ++++++ ...130170930_Added admin property.Designer.cs | 59 +++++++++++ .../20251130170930_Added admin property.cs | 29 ++++++ .../20251130175629_Added events.Designer.cs | 99 +++++++++++++++++++ .../Migrations/20251130175629_Added events.cs | 50 ++++++++++ .../DatabaseContextModelSnapshot.cs | 48 +++++++++ SpotiParty.Web/Models/Event.cs | 18 ++++ SpotiParty.Web/Models/User.cs | 9 +- SpotiParty.Web/Program.cs | 41 +++++++- .../Services/AuthorizationHandler.cs | 27 +++-- SpotiParty.Web/Services/ClientSideStorage.cs | 19 ++++ .../Services/DashboardAuthHandler.cs | 37 +++++++ SpotiParty.Web/SpotiParty.Web.csproj | 2 + SpotiParty.sln.DotSettings.user | 2 + 19 files changed, 523 insertions(+), 17 deletions(-) create mode 100644 SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.Designer.cs create mode 100644 SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.cs create mode 100644 SpotiParty.Web/Migrations/20251130170930_Added admin property.Designer.cs create mode 100644 SpotiParty.Web/Migrations/20251130170930_Added admin property.cs create mode 100644 SpotiParty.Web/Migrations/20251130175629_Added events.Designer.cs create mode 100644 SpotiParty.Web/Migrations/20251130175629_Added events.cs create mode 100644 SpotiParty.Web/Models/Event.cs create mode 100644 SpotiParty.Web/Services/ClientSideStorage.cs create mode 100644 SpotiParty.Web/Services/DashboardAuthHandler.cs diff --git a/SpotiParty.Web/Components/Pages/CallbackPage.razor b/SpotiParty.Web/Components/Pages/CallbackPage.razor index 515e0b0..cad9f52 100644 --- a/SpotiParty.Web/Components/Pages/CallbackPage.razor +++ b/SpotiParty.Web/Components/Pages/CallbackPage.razor @@ -22,6 +22,6 @@ } await AuthHandler.HandleCallback(Code); - Navigator.NavigateTo("/enqueue", forceLoad: true); + 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 5a95e06..01a00d1 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor @@ -9,7 +9,7 @@
- +
diff --git a/SpotiParty.Web/Components/Pages/LoginPage.razor b/SpotiParty.Web/Components/Pages/LoginPage.razor index 31adca1..fa4a32c 100644 --- a/SpotiParty.Web/Components/Pages/LoginPage.razor +++ b/SpotiParty.Web/Components/Pages/LoginPage.razor @@ -1,17 +1,15 @@ @page "/login" @using SpotiParty.Web.Services -Mit Spotify einloggen - @inject AuthorizationHandler AuthHandler +@inject NavigationManager Navigator @code { - private Uri _uri = null!; - protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - _uri = await AuthHandler.ConstructLoginUri(); + var uri = await AuthHandler.ConstructLoginUri(); + Navigator.NavigateTo(uri.ToString(), forceLoad: true); } } \ No newline at end of file diff --git a/SpotiParty.Web/DatabaseContext.cs b/SpotiParty.Web/DatabaseContext.cs index 1887175..831b9a5 100644 --- a/SpotiParty.Web/DatabaseContext.cs +++ b/SpotiParty.Web/DatabaseContext.cs @@ -6,5 +6,7 @@ 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/Migrations/20251130142549_Added Spotify Id.Designer.cs b/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.Designer.cs new file mode 100644 index 0000000..88af478 --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.Designer.cs @@ -0,0 +1,56 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpotiParty.Web; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20251130142549_Added Spotify Id")] + partial class AddedSpotifyId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpotiParty.Web.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SpotifyUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.cs b/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.cs new file mode 100644 index 0000000..3be1837 --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + /// + public partial class AddedSpotifyId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SpotifyUserId", + table: "Users", + type: "character varying(255)", + maxLength: 255, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SpotifyUserId", + table: "Users"); + } + } +} diff --git a/SpotiParty.Web/Migrations/20251130170930_Added admin property.Designer.cs b/SpotiParty.Web/Migrations/20251130170930_Added admin property.Designer.cs new file mode 100644 index 0000000..b2f6d2f --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130170930_Added admin property.Designer.cs @@ -0,0 +1,59 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpotiParty.Web; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20251130170930_Added admin property")] + partial class Addedadminproperty + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpotiParty.Web.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SpotifyUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SpotiParty.Web/Migrations/20251130170930_Added admin property.cs b/SpotiParty.Web/Migrations/20251130170930_Added admin property.cs new file mode 100644 index 0000000..bb8e788 --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130170930_Added admin property.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + /// + public partial class Addedadminproperty : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAdmin", + table: "Users", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAdmin", + table: "Users"); + } + } +} diff --git a/SpotiParty.Web/Migrations/20251130175629_Added events.Designer.cs b/SpotiParty.Web/Migrations/20251130175629_Added events.Designer.cs new file mode 100644 index 0000000..9776baa --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130175629_Added events.Designer.cs @@ -0,0 +1,99 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SpotiParty.Web; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20251130175629_Added events")] + partial class Addedevents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SpotiParty.Web.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("End") + .HasColumnType("timestamp with time zone"); + + b.Property("HostUserId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Start") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("HostUserId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("SpotiParty.Web.Models.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("RefreshToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SpotifyUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("UserId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("SpotiParty.Web.Models.Event", b => + { + b.HasOne("SpotiParty.Web.Models.User", "Host") + .WithMany() + .HasForeignKey("HostUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Host"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SpotiParty.Web/Migrations/20251130175629_Added events.cs b/SpotiParty.Web/Migrations/20251130175629_Added events.cs new file mode 100644 index 0000000..d0713f8 --- /dev/null +++ b/SpotiParty.Web/Migrations/20251130175629_Added events.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace SpotiParty.Web.Migrations +{ + /// + public partial class Addedevents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Events", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + HostUserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + Start = table.Column(type: "timestamp with time zone", nullable: false), + End = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Events", x => x.Id); + table.ForeignKey( + name: "FK_Events_Users_HostUserId", + column: x => x.HostUserId, + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Events_HostUserId", + table: "Events", + column: "HostUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Events"); + } + } +} diff --git a/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs index 715d899..dee540f 100644 --- a/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs +++ b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs @@ -22,6 +22,35 @@ namespace SpotiParty.Web.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("SpotiParty.Web.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("End") + .HasColumnType("timestamp with time zone"); + + b.Property("HostUserId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Start") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("HostUserId"); + + b.ToTable("Events"); + }); + modelBuilder.Entity("SpotiParty.Web.Models.User", b => { b.Property("UserId") @@ -33,15 +62,34 @@ namespace SpotiParty.Web.Migrations .HasMaxLength(255) .HasColumnType("character varying(255)"); + b.Property("IsAdmin") + .HasColumnType("boolean"); + b.Property("RefreshToken") .IsRequired() .HasMaxLength(255) .HasColumnType("character varying(255)"); + b.Property("SpotifyUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + b.HasKey("UserId"); b.ToTable("Users"); }); + + modelBuilder.Entity("SpotiParty.Web.Models.Event", b => + { + b.HasOne("SpotiParty.Web.Models.User", "Host") + .WithMany() + .HasForeignKey("HostUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Host"); + }); #pragma warning restore 612, 618 } } diff --git a/SpotiParty.Web/Models/Event.cs b/SpotiParty.Web/Models/Event.cs new file mode 100644 index 0000000..df118d2 --- /dev/null +++ b/SpotiParty.Web/Models/Event.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace SpotiParty.Web.Models; + +public class Event { + [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; init; } + + public required User Host { get; init; } + + [MaxLength(255)] + public required string Name { get; set; } + + public DateTime Start { get; set; } + + public DateTime End { get; set; } +} \ No newline at end of file diff --git a/SpotiParty.Web/Models/User.cs b/SpotiParty.Web/Models/User.cs index 2e4da79..d17c980 100644 --- a/SpotiParty.Web/Models/User.cs +++ b/SpotiParty.Web/Models/User.cs @@ -5,10 +5,15 @@ 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 string DisplayName { get; init; } + public required string DisplayName { get; init; } [MaxLength(255)] - public string RefreshToken { get; set; } + 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 f0a16a0..d00a87e 100644 --- a/SpotiParty.Web/Program.cs +++ b/SpotiParty.Web/Program.cs @@ -1,6 +1,9 @@ +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); @@ -11,9 +14,44 @@ builder.Services.AddRazorComponents() builder.AddServiceDefaults(); +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.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); + + table.ShowSearchSuggestions(false); + }); + + context.Table(table => { + table.Property(e => e.Id) + .List(false) + .SetEditable(false); + + table.ShowSearchSuggestions(false); + }); + }); +}); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -37,6 +75,7 @@ app.UseAntiforgery(); app.MapStaticAssets(); app.MapRazorComponents() - .AddInteractiveServerRenderMode(); + .AddInteractiveServerRenderMode() + .AddHopFramePages(); app.Run(); \ No newline at end of file diff --git a/SpotiParty.Web/Services/AuthorizationHandler.cs b/SpotiParty.Web/Services/AuthorizationHandler.cs index 1cb8e80..8a3f5bc 100644 --- a/SpotiParty.Web/Services/AuthorizationHandler.cs +++ b/SpotiParty.Web/Services/AuthorizationHandler.cs @@ -1,10 +1,11 @@ using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; using SpotifyAPI.Web; using SpotiParty.Web.Models; namespace SpotiParty.Web.Services; -public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseContext context) { +public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseContext context, ClientSideStorage storage) { private async Task<(string clientId, string clientSecret)> GetClientSecrets() { var fileLines = await File.ReadAllLinesAsync(Path.Combine(Environment.CurrentDirectory, ".dev-token")); @@ -45,13 +46,25 @@ public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseCo var client = new SpotifyClient(response.AccessToken); var spotiUser = await client.UserProfile.Current(); - var user = new User { - DisplayName = spotiUser.DisplayName, - RefreshToken = response.RefreshToken - }; - await context.Users.AddAsync(user); - await context.SaveChangesAsync(); + 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(); + } + 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..05a7800 --- /dev/null +++ b/SpotiParty.Web/Services/DashboardAuthHandler.cs @@ -0,0 +1,37 @@ +using HopFrame.Core.Services; +using Microsoft.EntityFrameworkCore; + +namespace SpotiParty.Web.Services; + +public class DashboardAuthHandler(ClientSideStorage storage, IDbContextFactory contextFactory) : IHopFrameAuthHandler { + + public const string AdminPolicy = "ADMIN"; + + public async Task IsAuthenticatedAsync(string? policy) { + var token = storage.GetUserToken(); + if (string.IsNullOrWhiteSpace(token)) + return false; + + await using var context = await contextFactory.CreateDbContextAsync(); + var user = await context.Users.AsNoTracking().FirstOrDefaultAsync(u => u.RefreshToken == token); + 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; + } +} \ No newline at end of file diff --git a/SpotiParty.Web/SpotiParty.Web.csproj b/SpotiParty.Web/SpotiParty.Web.csproj index 2e2e953..5b81409 100644 --- a/SpotiParty.Web/SpotiParty.Web.csproj +++ b/SpotiParty.Web/SpotiParty.Web.csproj @@ -20,6 +20,8 @@ + + all diff --git a/SpotiParty.sln.DotSettings.user b/SpotiParty.sln.DotSettings.user index 8696f24..92a7f3d 100644 --- a/SpotiParty.sln.DotSettings.user +++ b/SpotiParty.sln.DotSettings.user @@ -1,4 +1,6 @@  + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded \ No newline at end of file -- 2.49.1 From 8d0573eb7e539cc57cae5a6ee5ef098f64c7c321 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 30 Nov 2025 20:14:08 +0100 Subject: [PATCH 3/4] Updaed event logic and event creation system --- SpotiParty.AppHost/AppHost.cs | 2 +- .../Components/Pages/EnqueuePage.razor | 2 +- .../Components/Pages/EnqueuePage.razor.cs | 24 ++++- .../20251130141048_Initial.Designer.cs | 51 ---------- .../Migrations/20251130141048_Initial.cs | 35 ------- ...0251130142549_Added Spotify Id.Designer.cs | 56 ----------- .../20251130142549_Added Spotify Id.cs | 30 ------ ...130170930_Added admin property.Designer.cs | 59 ----------- .../20251130170930_Added admin property.cs | 29 ------ .../20251130175629_Added events.Designer.cs | 99 ------------------- .../Migrations/20251130175629_Added events.cs | 50 ---------- .../DatabaseContextModelSnapshot.cs | 96 ------------------ SpotiParty.Web/Models/Event.cs | 11 +-- SpotiParty.Web/Program.cs | 33 ++++++- .../Services/DashboardAuthHandler.cs | 19 ++-- .../Services/EventsDashboardRepo.cs | 49 +++++++++ SpotiParty.Web/SpotiParty.Web.csproj | 4 + SpotiParty.sln.DotSettings.user | 1 + 18 files changed, 122 insertions(+), 528 deletions(-) delete mode 100644 SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs delete mode 100644 SpotiParty.Web/Migrations/20251130141048_Initial.cs delete mode 100644 SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.Designer.cs delete mode 100644 SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.cs delete mode 100644 SpotiParty.Web/Migrations/20251130170930_Added admin property.Designer.cs delete mode 100644 SpotiParty.Web/Migrations/20251130170930_Added admin property.cs delete mode 100644 SpotiParty.Web/Migrations/20251130175629_Added events.Designer.cs delete mode 100644 SpotiParty.Web/Migrations/20251130175629_Added events.cs delete mode 100644 SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs create mode 100644 SpotiParty.Web/Services/EventsDashboardRepo.cs diff --git a/SpotiParty.AppHost/AppHost.cs b/SpotiParty.AppHost/AppHost.cs index 180f365..af99f1e 100644 --- a/SpotiParty.AppHost/AppHost.cs +++ b/SpotiParty.AppHost/AppHost.cs @@ -3,7 +3,7 @@ using Projects; var builder = DistributedApplication.CreateBuilder(args); var dbServer = builder.AddPostgres("database") - .WithDataVolume("database-data"); + .WithDataVolume(); var database = dbServer.AddDatabase("SpotiParty"); diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor b/SpotiParty.Web/Components/Pages/EnqueuePage.razor index 01a00d1..313dcc3 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor @@ -1,4 +1,4 @@ -@page "/enqueue/{userid}" +@page "/enqueue/{eventId}" @using SpotiParty.Web.Components.Components @rendermode InteractiveServer diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs index 5d7b76d..637bd6d 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs @@ -1,13 +1,14 @@ using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; using SpotifyAPI.Web; using SpotiParty.Web.Services; namespace SpotiParty.Web.Components.Pages; -public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationManager navigator) : ComponentBase { +public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationManager navigator, DatabaseContext context) : ComponentBase { [Parameter] - public string UserId { get; set; } = string.Empty; + public string EventId { get; set; } = string.Empty; private readonly int _currentYear = DateTime.Now.Year; private SpotifyClient _client = null!; @@ -22,12 +23,27 @@ public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationMan protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - if (!Guid.TryParse(UserId, out var guid)) { + if (!Guid.TryParse(EventId, out var guid)) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + var eventEntry = await context.Events + .Include(e => e.Host) + .FirstOrDefaultAsync(e => e.Id == guid); + + if (eventEntry is null) { + navigator.NavigateTo("/", forceLoad: true); + return; + } + + var now = DateTime.Now; + if (eventEntry.Start > now || eventEntry.End < now) { navigator.NavigateTo("/", forceLoad: true); return; } - var client = await authHandler.ConfigureClient(guid); + var client = await authHandler.ConfigureClient(eventEntry.Host.UserId); if (client is null) { navigator.NavigateTo("/", forceLoad: true); diff --git a/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs b/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs deleted file mode 100644 index a6212ea..0000000 --- a/SpotiParty.Web/Migrations/20251130141048_Initial.Designer.cs +++ /dev/null @@ -1,51 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using SpotiParty.Web; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20251130141048_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("SpotiParty.Web.Models.User", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("RefreshToken") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.HasKey("UserId"); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/SpotiParty.Web/Migrations/20251130141048_Initial.cs b/SpotiParty.Web/Migrations/20251130141048_Initial.cs deleted file mode 100644 index e17be45..0000000 --- a/SpotiParty.Web/Migrations/20251130141048_Initial.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - UserId = table.Column(type: "uuid", nullable: false), - DisplayName = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), - RefreshToken = table.Column(type: "character varying(255)", maxLength: 255, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.UserId); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Users"); - } - } -} diff --git a/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.Designer.cs b/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.Designer.cs deleted file mode 100644 index 88af478..0000000 --- a/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.Designer.cs +++ /dev/null @@ -1,56 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using SpotiParty.Web; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20251130142549_Added Spotify Id")] - partial class AddedSpotifyId - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("SpotiParty.Web.Models.User", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("RefreshToken") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("SpotifyUserId") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.HasKey("UserId"); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.cs b/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.cs deleted file mode 100644 index 3be1837..0000000 --- a/SpotiParty.Web/Migrations/20251130142549_Added Spotify Id.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - /// - public partial class AddedSpotifyId : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SpotifyUserId", - table: "Users", - type: "character varying(255)", - maxLength: 255, - nullable: false, - defaultValue: ""); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "SpotifyUserId", - table: "Users"); - } - } -} diff --git a/SpotiParty.Web/Migrations/20251130170930_Added admin property.Designer.cs b/SpotiParty.Web/Migrations/20251130170930_Added admin property.Designer.cs deleted file mode 100644 index b2f6d2f..0000000 --- a/SpotiParty.Web/Migrations/20251130170930_Added admin property.Designer.cs +++ /dev/null @@ -1,59 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using SpotiParty.Web; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20251130170930_Added admin property")] - partial class Addedadminproperty - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("SpotiParty.Web.Models.User", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("IsAdmin") - .HasColumnType("boolean"); - - b.Property("RefreshToken") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("SpotifyUserId") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.HasKey("UserId"); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/SpotiParty.Web/Migrations/20251130170930_Added admin property.cs b/SpotiParty.Web/Migrations/20251130170930_Added admin property.cs deleted file mode 100644 index bb8e788..0000000 --- a/SpotiParty.Web/Migrations/20251130170930_Added admin property.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - /// - public partial class Addedadminproperty : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "IsAdmin", - table: "Users", - type: "boolean", - nullable: false, - defaultValue: false); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "IsAdmin", - table: "Users"); - } - } -} diff --git a/SpotiParty.Web/Migrations/20251130175629_Added events.Designer.cs b/SpotiParty.Web/Migrations/20251130175629_Added events.Designer.cs deleted file mode 100644 index 9776baa..0000000 --- a/SpotiParty.Web/Migrations/20251130175629_Added events.Designer.cs +++ /dev/null @@ -1,99 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using SpotiParty.Web; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - [DbContext(typeof(DatabaseContext))] - [Migration("20251130175629_Added events")] - partial class Addedevents - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("SpotiParty.Web.Models.Event", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("End") - .HasColumnType("timestamp with time zone"); - - b.Property("HostUserId") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("Start") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("HostUserId"); - - b.ToTable("Events"); - }); - - modelBuilder.Entity("SpotiParty.Web.Models.User", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("IsAdmin") - .HasColumnType("boolean"); - - b.Property("RefreshToken") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("SpotifyUserId") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.HasKey("UserId"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("SpotiParty.Web.Models.Event", b => - { - b.HasOne("SpotiParty.Web.Models.User", "Host") - .WithMany() - .HasForeignKey("HostUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Host"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/SpotiParty.Web/Migrations/20251130175629_Added events.cs b/SpotiParty.Web/Migrations/20251130175629_Added events.cs deleted file mode 100644 index d0713f8..0000000 --- a/SpotiParty.Web/Migrations/20251130175629_Added events.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - /// - public partial class Addedevents : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Events", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - HostUserId = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), - Start = table.Column(type: "timestamp with time zone", nullable: false), - End = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Events", x => x.Id); - table.ForeignKey( - name: "FK_Events_Users_HostUserId", - column: x => x.HostUserId, - principalTable: "Users", - principalColumn: "UserId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_Events_HostUserId", - table: "Events", - column: "HostUserId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Events"); - } - } -} diff --git a/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs b/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs deleted file mode 100644 index dee540f..0000000 --- a/SpotiParty.Web/Migrations/DatabaseContextModelSnapshot.cs +++ /dev/null @@ -1,96 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using SpotiParty.Web; - -#nullable disable - -namespace SpotiParty.Web.Migrations -{ - [DbContext(typeof(DatabaseContext))] - partial class DatabaseContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("SpotiParty.Web.Models.Event", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("End") - .HasColumnType("timestamp with time zone"); - - b.Property("HostUserId") - .HasColumnType("uuid"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("Start") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("HostUserId"); - - b.ToTable("Events"); - }); - - modelBuilder.Entity("SpotiParty.Web.Models.User", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("IsAdmin") - .HasColumnType("boolean"); - - b.Property("RefreshToken") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.Property("SpotifyUserId") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("character varying(255)"); - - b.HasKey("UserId"); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("SpotiParty.Web.Models.Event", b => - { - b.HasOne("SpotiParty.Web.Models.User", "Host") - .WithMany() - .HasForeignKey("HostUserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Host"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/SpotiParty.Web/Models/Event.cs b/SpotiParty.Web/Models/Event.cs index df118d2..a7b9ee3 100644 --- a/SpotiParty.Web/Models/Event.cs +++ b/SpotiParty.Web/Models/Event.cs @@ -1,18 +1,17 @@ using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; namespace SpotiParty.Web.Models; public class Event { - [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; init; } + [Key] + public Guid Id { get; init; } = Guid.CreateVersion7(); - public required User Host { get; init; } + public required User Host { get; set; } [MaxLength(255)] public required string Name { get; set; } - public DateTime Start { get; set; } + public DateTime Start { get; set; } = DateTime.Today; - public DateTime End { get; set; } + public DateTime End { get; set; } = DateTime.Today + TimeSpan.FromDays(1); } \ No newline at end of file diff --git a/SpotiParty.Web/Program.cs b/SpotiParty.Web/Program.cs index d00a87e..8ac0ee7 100644 --- a/SpotiParty.Web/Program.cs +++ b/SpotiParty.Web/Program.cs @@ -1,3 +1,4 @@ +using HopFrame.Core.Callbacks; using HopFrame.Core.Services; using HopFrame.Web; using Microsoft.EntityFrameworkCore; @@ -14,6 +15,9 @@ builder.Services.AddRazorComponents() builder.AddServiceDefaults(); +AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); +AppContext.SetSwitch("Npgsql.DisableDateTimeInfinityConversions", true); + builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.AddNpgsqlDbContext("SpotiParty"); @@ -23,6 +27,8 @@ builder.Services.AddDbContextFactory(options => { builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHopFrame(config => { config.SetLoginPage("/login"); @@ -42,12 +48,29 @@ builder.Services.AddHopFrame(config => { table.ShowSearchSuggestions(false); }); - context.Table(table => { - table.Property(e => e.Id) - .List(false) - .SetEditable(false); + context.Table() + .Ignore(true); + }); - table.ShowSearchSuggestions(false); + 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) + .SetEditable(false) + .SetCreatable(false) + .SetDisplayedProperty(u => u.DisplayName); + + table.ShowSearchSuggestions(false); + + table.AddCallbackHandler(CallbackType.CreateEntry, async (entry, services) => { + var auth = services.GetRequiredService(); + var user = await auth.GetCurrentUser(); + entry.Host = user!; }); }); }); diff --git a/SpotiParty.Web/Services/DashboardAuthHandler.cs b/SpotiParty.Web/Services/DashboardAuthHandler.cs index 05a7800..ced6f4c 100644 --- a/SpotiParty.Web/Services/DashboardAuthHandler.cs +++ b/SpotiParty.Web/Services/DashboardAuthHandler.cs @@ -1,5 +1,6 @@ using HopFrame.Core.Services; using Microsoft.EntityFrameworkCore; +using SpotiParty.Web.Models; namespace SpotiParty.Web.Services; @@ -8,14 +9,10 @@ public class DashboardAuthHandler(ClientSideStorage storage, IDbContextFactory IsAuthenticatedAsync(string? policy) { - var token = storage.GetUserToken(); - if (string.IsNullOrWhiteSpace(token)) + var user = await GetCurrentUser(); + if (user is null) return false; - await using var context = await contextFactory.CreateDbContextAsync(); - var user = await context.Users.AsNoTracking().FirstOrDefaultAsync(u => u.RefreshToken == token); - if (user is null) return false; - if (policy == AdminPolicy) { return user.IsAdmin; } @@ -34,4 +31,14 @@ public class DashboardAuthHandler(ClientSideStorage storage, IDbContextFactory 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..52475ce --- /dev/null +++ b/SpotiParty.Web/Services/EventsDashboardRepo.cs @@ -0,0 +1,49 @@ +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) { + 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 5b81409..fdd4d21 100644 --- a/SpotiParty.Web/SpotiParty.Web.csproj +++ b/SpotiParty.Web/SpotiParty.Web.csproj @@ -83,4 +83,8 @@ + + + + diff --git a/SpotiParty.sln.DotSettings.user b/SpotiParty.sln.DotSettings.user index 92a7f3d..39a1eca 100644 --- a/SpotiParty.sln.DotSettings.user +++ b/SpotiParty.sln.DotSettings.user @@ -2,5 +2,6 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded \ No newline at end of file -- 2.49.1 From 2d3c973d4796bfd32fbf4cf2e07a213af935d8b8 Mon Sep 17 00:00:00 2001 From: Leon Hoppe Date: Sun, 18 Jan 2026 19:30:20 +0100 Subject: [PATCH 4/4] Switched to net9 and finished event workflow --- .gitlab-ci.yml | 41 ++++++++++++++++++ SpotiParty.AppHost/SpotiParty.AppHost.csproj | 2 +- .../SpotiParty.Defaults.csproj | 2 +- SpotiParty.Web/Components/App.razor | 2 +- .../Components/Pages/EnqueuePage.razor | 6 ++- .../Components/Pages/EnqueuePage.razor.cs | 23 ++++++---- .../Components/Pages/EnqueuePage.razor.css | 5 +++ .../Components/Pages/HomePage.razor | 6 +++ .../Components/Pages/HomePage.razor.css | 1 + .../Components/Pages/NotFound.razor | 2 +- SpotiParty.Web/Components/Routes.razor | 2 +- SpotiParty.Web/Dockerfile | 4 +- SpotiParty.Web/Models/Event.cs | 4 +- SpotiParty.Web/Program.cs | 19 ++++---- .../Services/AdminDashboardPlugin.cs | 27 ++++++++++++ .../Services/AuthorizationHandler.cs | 8 +++- .../Services/EventsDashboardRepo.cs | 4 ++ SpotiParty.Web/SpotiParty.Web.csproj | 11 +++-- SpotiParty.Web/appsettings.Development.json | 4 +- SpotiParty.Web/appsettings.json | 4 +- SpotiParty.Web/wwwroot/favicon.ico | Bin 0 -> 13745 bytes SpotiParty.Web/wwwroot/favicon.png | Bin 1148 -> 0 bytes SpotiParty.sln.DotSettings.user | 5 ++- 23 files changed, 142 insertions(+), 40 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 SpotiParty.Web/Components/Pages/HomePage.razor create mode 100644 SpotiParty.Web/Components/Pages/HomePage.razor.css create mode 100644 SpotiParty.Web/Services/AdminDashboardPlugin.cs create mode 100644 SpotiParty.Web/wwwroot/favicon.ico delete mode 100644 SpotiParty.Web/wwwroot/favicon.png 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/SpotiParty.AppHost/SpotiParty.AppHost.csproj b/SpotiParty.AppHost/SpotiParty.AppHost.csproj index 6b1b894..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 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/EnqueuePage.razor b/SpotiParty.Web/Components/Pages/EnqueuePage.razor index 313dcc3..8c33bb7 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor @@ -2,8 +2,10 @@ @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 637bd6d..3ecd2d5 100644 --- a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs +++ b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.EntityFrameworkCore; using SpotifyAPI.Web; +using SpotiParty.Web.Models; using SpotiParty.Web.Services; namespace SpotiParty.Web.Components.Pages; @@ -9,6 +10,8 @@ public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationMan [Parameter] public string EventId { get; set; } = string.Empty; + + private Event? _event; private readonly int _currentYear = DateTime.Now.Year; private SpotifyClient _client = null!; @@ -28,22 +31,24 @@ public partial class EnqueuePage(AuthorizationHandler authHandler, NavigationMan return; } - var eventEntry = await context.Events + _event = await context.Events .Include(e => e.Host) .FirstOrDefaultAsync(e => e.Id == guid); - if (eventEntry is null) { - navigator.NavigateTo("/", forceLoad: true); - return; - } - - var now = DateTime.Now; - if (eventEntry.Start > now || eventEntry.End < now) { + if (_event is null) { navigator.NavigateTo("/", forceLoad: true); return; } - var client = await authHandler.ConfigureClient(eventEntry.Host.UserId); + 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); diff --git a/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css b/SpotiParty.Web/Components/Pages/EnqueuePage.razor.css index 3a1ded2..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 { 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/NotFound.razor b/SpotiParty.Web/Components/Pages/NotFound.razor index 76193cc..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("/login", 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/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 index a7b9ee3..41dc28c 100644 --- a/SpotiParty.Web/Models/Event.cs +++ b/SpotiParty.Web/Models/Event.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace SpotiParty.Web.Models; @@ -6,7 +7,8 @@ public class Event { [Key] public Guid Id { get; init; } = Guid.CreateVersion7(); - public required User Host { get; set; } + [ForeignKey("host")] + public virtual required User Host { get; set; } [MaxLength(255)] public required string Name { get; set; } diff --git a/SpotiParty.Web/Program.cs b/SpotiParty.Web/Program.cs index 8ac0ee7..53c8539 100644 --- a/SpotiParty.Web/Program.cs +++ b/SpotiParty.Web/Program.cs @@ -1,4 +1,3 @@ -using HopFrame.Core.Callbacks; using HopFrame.Core.Services; using HopFrame.Web; using Microsoft.EntityFrameworkCore; @@ -9,6 +8,8 @@ using SpotiParty.Web.Services; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddEnvironmentVariables(); + // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -44,16 +45,15 @@ builder.Services.AddHopFrame(config => { .List(false) .DisplayValue(false) .SetEditable(false); - - table.ShowSearchSuggestions(false); }); context.Table() + .SetDisplayName(Guid.NewGuid().ToString()) .Ignore(true); }); config.AddCustomRepository(e => e.Id, table => { - //table.SetDisplayName("Events"); + table.SetDisplayName("Events"); table.Property(e => e.Id) .List(false) @@ -61,18 +61,15 @@ builder.Services.AddHopFrame(config => { .SetCreatable(false); table.Property(e => e.Host) + .List(false) .SetEditable(false) .SetCreatable(false) .SetDisplayedProperty(u => u.DisplayName); table.ShowSearchSuggestions(false); - - table.AddCallbackHandler(CallbackType.CreateEntry, async (entry, services) => { - var auth = services.GetRequiredService(); - var user = await auth.GetCurrentUser(); - entry.Host = user!; - }); }); + + config.AddPlugin(); }); var app = builder.Build(); @@ -91,7 +88,7 @@ await using (var scope = app.Services.CreateAsyncScope()) { app.MapDefaultEndpoints(); -app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseStatusCodePagesWithReExecute("/not-found"); //app.UseHttpsRedirection(); app.UseAntiforgery(); 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 8a3f5bc..8ef99f4 100644 --- a/SpotiParty.Web/Services/AuthorizationHandler.cs +++ b/SpotiParty.Web/Services/AuthorizationHandler.cs @@ -5,11 +5,17 @@ using SpotiParty.Web.Models; namespace SpotiParty.Web.Services; -public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseContext context, ClientSideStorage storage) { +public sealed class AuthorizationHandler(NavigationManager navigator, DatabaseContext context, ClientSideStorage storage, IConfiguration configuration) { 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(Guid userId) { diff --git a/SpotiParty.Web/Services/EventsDashboardRepo.cs b/SpotiParty.Web/Services/EventsDashboardRepo.cs index 52475ce..496b58e 100644 --- a/SpotiParty.Web/Services/EventsDashboardRepo.cs +++ b/SpotiParty.Web/Services/EventsDashboardRepo.cs @@ -29,6 +29,10 @@ public class EventsDashboardRepo(DatabaseContext context, DashboardAuthHandler h } 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(); } diff --git a/SpotiParty.Web/SpotiParty.Web.csproj b/SpotiParty.Web/SpotiParty.Web.csproj index fdd4d21..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,11 +19,10 @@ - - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive 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/favicon.ico b/SpotiParty.Web/wwwroot/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..402e5c8c5c7df18edaeab73114d16f57363734e1 GIT binary patch literal 13745 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 literal 0 HcmV?d00001 diff --git a/SpotiParty.Web/wwwroot/favicon.png b/SpotiParty.Web/wwwroot/favicon.png deleted file mode 100644 index 8422b59695935d180d11d5dbe99653e711097819..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ + True + True ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded - ForceIncluded \ No newline at end of file + ForceIncluded + ForceIncluded \ No newline at end of file -- 2.49.1