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