Initial Scaffold

This commit is contained in:
Meta Vens 2026-04-21 20:08:49 +01:00
commit 7cbdb93639
118 changed files with 87220 additions and 9 deletions

482
.gitignore vendored Normal file
View file

@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

8
Bifrost.slnx Normal file
View file

@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Bifrost.Web/Bifrost.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj" />
</Folder>
</Solution>

View file

@ -4,7 +4,13 @@ Bifrost is the planned GameCult member, governance, and labor platform running u
## Status ## Status
This repository is in planning/bootstrap only. It is intentionally a docs-first repo shell with no ASP.NET application code yet. This repository now includes the first implementation slice of the ASP.NET Core app:
- Razor Pages app scaffold under `src/Bifrost.Web`
- PostgreSQL-backed EF Core domain model for members, projects, work items, motions, and ledgers
- GitHub OAuth wiring plus invite/approval-based membership gating
- initial member console pages for projects, work items, motions, ledger activity, and member approvals
- xUnit integration tests under `tests/Bifrost.Web.Tests`
## Chosen Stack ## Chosen Stack
@ -31,13 +37,21 @@ This repository is in planning/bootstrap only. It is intentionally a docs-first
## Next Build Target ## Next Build Target
The first implementation milestone should cover: The next build target is to deepen the first implementation milestone by adding:
- authentication and membership gating - database migrations and bootstrap seed/run instructions
- project and member models - GitHub issue and pull request sync
- work items from GitHub and internal sources - richer membership/admin role management
- motions and votes - work item completion and ledger approval flow
- internal ledgers and payout proposal batches - payout proposal batch generation and review
## Local Dev Notes
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
- default connection string points at local PostgreSQL
- build with `dotnet build Bifrost.slnx`
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
## Read First ## Read First

View file

@ -1 +0,0 @@

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,8 @@
namespace Bifrost.Web.Configuration;
public sealed class BootstrapOptions
{
public const string SectionName = "Bootstrap";
public string[] AdminGitHubLogins { get; init; } = [];
}

View file

@ -0,0 +1,16 @@
namespace Bifrost.Web.Configuration;
public sealed class GitHubOAuthOptions
{
public const string SectionName = "GitHubOAuth";
public string ClientId { get; init; } = string.Empty;
public string ClientSecret { get; init; } = string.Empty;
public string CallbackPath { get; init; } = "/signin-github";
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(ClientId) &&
!string.IsNullOrWhiteSpace(ClientSecret);
}

View file

@ -0,0 +1,212 @@
using Bifrost.Web.Domain;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Data;
public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options) : DbContext(options)
{
public DbSet<UserAccount> UserAccounts => Set<UserAccount>();
public DbSet<MemberProfile> MemberProfiles => Set<MemberProfile>();
public DbSet<Membership> Memberships => Set<Membership>();
public DbSet<MembershipInvitation> MembershipInvitations => Set<MembershipInvitation>();
public DbSet<Project> Projects => Set<Project>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
public DbSet<VolunteerClaim> VolunteerClaims => Set<VolunteerClaim>();
public DbSet<Assignment> Assignments => Set<Assignment>();
public DbSet<Motion> Motions => Set<Motion>();
public DbSet<Vote> Votes => Set<Vote>();
public DbSet<LedgerEntry> LedgerEntries => Set<LedgerEntry>();
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(entity =>
{
entity.HasIndex(x => x.GitHubUserId).IsUnique();
entity.HasIndex(x => x.NormalizedGitHubLogin).IsUnique();
entity.Property(x => x.GitHubLogin).HasMaxLength(120);
entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120);
entity.Property(x => x.DisplayName).HasMaxLength(200);
entity.Property(x => x.AvatarUrl).HasMaxLength(500);
});
modelBuilder.Entity<MemberProfile>(entity =>
{
entity.HasOne(x => x.UserAccount)
.WithOne(x => x.MemberProfile)
.HasForeignKey<MemberProfile>(x => x.UserAccountId);
});
modelBuilder.Entity<Membership>(entity =>
{
entity.HasOne(x => x.UserAccount)
.WithOne(x => x.Membership)
.HasForeignKey<Membership>(x => x.UserAccountId);
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.PatronWeight).HasPrecision(18, 2);
entity.Property(x => x.ContributorWeight).HasPrecision(18, 2);
});
modelBuilder.Entity<MembershipInvitation>(entity =>
{
entity.HasIndex(x => x.NormalizedGitHubLogin).IsUnique();
entity.Property(x => x.GitHubLogin).HasMaxLength(120);
entity.Property(x => x.NormalizedGitHubLogin).HasMaxLength(120);
entity.HasOne(x => x.IssuedByUserAccount)
.WithMany()
.HasForeignKey(x => x.IssuedByUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.AcceptedByUserAccount)
.WithMany()
.HasForeignKey(x => x.AcceptedByUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Project>(entity =>
{
entity.HasIndex(x => x.Slug).IsUnique();
entity.Property(x => x.Name).HasMaxLength(180);
entity.Property(x => x.Slug).HasMaxLength(120);
entity.Property(x => x.Status).HasConversion<string>();
entity.HasOne(x => x.OwnerUserAccount)
.WithMany(x => x.OwnedProjects)
.HasForeignKey(x => x.OwnerUserAccountId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<WorkItem>(entity =>
{
entity.Property(x => x.SourceType).HasConversion<string>();
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.Title).HasMaxLength(180);
entity.Property(x => x.ExternalSourceId).HasMaxLength(120);
entity.Property(x => x.EffortPoints).HasPrecision(18, 2);
entity.HasOne(x => x.Project)
.WithMany(x => x.WorkItems)
.HasForeignKey(x => x.ProjectId);
entity.HasOne(x => x.RequestedByUserAccount)
.WithMany(x => x.RequestedWorkItems)
.HasForeignKey(x => x.RequestedByUserAccountId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.AssignedToUserAccount)
.WithMany(x => x.AssignedWorkItems)
.HasForeignKey(x => x.AssignedToUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<VolunteerClaim>(entity =>
{
entity.HasIndex(x => new { x.WorkItemId, x.UserAccountId }).IsUnique();
entity.Property(x => x.Status).HasConversion<string>();
entity.HasOne(x => x.WorkItem)
.WithMany(x => x.VolunteerClaims)
.HasForeignKey(x => x.WorkItemId);
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.VolunteerClaims)
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<Assignment>(entity =>
{
entity.HasOne(x => x.WorkItem)
.WithMany(x => x.Assignments)
.HasForeignKey(x => x.WorkItemId);
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.Assignments)
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.AssignedByUserAccount)
.WithMany()
.HasForeignKey(x => x.AssignedByUserAccountId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<Motion>(entity =>
{
entity.Property(x => x.Scope).HasConversion<string>();
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3);
entity.HasOne(x => x.Project)
.WithMany(x => x.Motions)
.HasForeignKey(x => x.ProjectId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.CreatedByUserAccount)
.WithMany(x => x.CreatedMotions)
.HasForeignKey(x => x.CreatedByUserAccountId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<Vote>(entity =>
{
entity.HasIndex(x => new { x.MotionId, x.UserAccountId }).IsUnique();
entity.Property(x => x.Choice).HasConversion<string>();
entity.Property(x => x.Weight).HasPrecision(18, 2);
entity.HasOne(x => x.Motion)
.WithMany(x => x.Votes)
.HasForeignKey(x => x.MotionId);
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.Votes)
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<LedgerEntry>(entity =>
{
entity.Property(x => x.EntryType).HasConversion<string>();
entity.Property(x => x.Status).HasConversion<string>();
entity.Property(x => x.Points).HasPrecision(18, 2);
entity.Property(x => x.NominalAmount).HasPrecision(18, 2);
entity.HasOne(x => x.UserAccount)
.WithMany(x => x.LedgerEntries)
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.CreatedByUserAccount)
.WithMany()
.HasForeignKey(x => x.CreatedByUserAccountId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Project)
.WithMany(x => x.LedgerEntries)
.HasForeignKey(x => x.ProjectId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(x => x.WorkItem)
.WithMany(x => x.LedgerEntries)
.HasForeignKey(x => x.WorkItemId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<PayoutProposalBatch>(entity =>
{
entity.Property(x => x.Status).HasConversion<string>();
entity.HasOne(x => x.CreatedByUserAccount)
.WithMany()
.HasForeignKey(x => x.CreatedByUserAccountId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<AuditEvent>(entity =>
{
entity.Property(x => x.EntityType).HasMaxLength(120);
entity.Property(x => x.EntityId).HasMaxLength(120);
entity.Property(x => x.Action).HasMaxLength(120);
entity.HasOne(x => x.ActorUserAccount)
.WithMany(x => x.AuditEvents)
.HasForeignKey(x => x.ActorUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
}
}

View file

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Bifrost.Web.Data;
public sealed class BifrostDbContextFactory : IDesignTimeDbContextFactory<BifrostDbContext>
{
public BifrostDbContext CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var connectionString = configuration.GetConnectionString("Bifrost")
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
var options = new DbContextOptionsBuilder<BifrostDbContext>()
.UseNpgsql(connectionString)
.Options;
return new BifrostDbContext(options);
}
}

View file

@ -0,0 +1,430 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Bifrost.Web.Domain;
public enum MembershipStatus
{
Authenticated,
PendingApproval,
Active,
Suspended
}
public enum ProjectStatus
{
Proposed,
Active,
Paused,
Archived
}
public enum WorkItemSourceType
{
GitHubIssue,
Internal
}
public enum WorkItemStatus
{
Backlog,
Open,
Claimed,
InProgress,
Done,
Blocked,
Archived
}
public enum VolunteerClaimStatus
{
Active,
Withdrawn,
Accepted
}
public enum MotionScope
{
Management,
Project
}
public enum MotionStatus
{
Open,
Passed,
Failed,
Closed
}
public enum VoteChoice
{
For,
Against,
Abstain
}
public enum LedgerEntryType
{
PatronCredit,
ContributionCredit,
NominalCompensation,
Adjustment
}
public enum LedgerEntryStatus
{
Draft,
Approved,
IncludedInProposal
}
public enum PayoutProposalBatchStatus
{
Draft,
Reviewed,
Closed
}
public sealed class UserAccount
{
public Guid Id { get; set; } = Guid.NewGuid();
public long GitHubUserId { get; set; }
public string GitHubLogin { get; set; } = string.Empty;
public string NormalizedGitHubLogin { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string AvatarUrl { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset LastSeenAtUtc { get; set; }
public MemberProfile? MemberProfile { get; set; }
public Membership? Membership { get; set; }
public ICollection<Project> OwnedProjects { get; set; } = [];
public ICollection<WorkItem> RequestedWorkItems { get; set; } = [];
public ICollection<WorkItem> AssignedWorkItems { get; set; } = [];
public ICollection<VolunteerClaim> VolunteerClaims { get; set; } = [];
public ICollection<Assignment> Assignments { get; set; } = [];
public ICollection<Motion> CreatedMotions { get; set; } = [];
public ICollection<Vote> Votes { get; set; } = [];
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
public ICollection<AuditEvent> AuditEvents { get; set; } = [];
}
public sealed class MemberProfile
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserAccountId { get; set; }
public string Headline { get; set; } = string.Empty;
public string Skills { get; set; } = string.Empty;
public string Availability { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
public DateTimeOffset UpdatedAtUtc { get; set; }
public UserAccount UserAccount { get; set; } = null!;
}
public sealed class Membership
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserAccountId { get; set; }
public MembershipStatus Status { get; set; } = MembershipStatus.Authenticated;
public bool IsPlatformAdmin { get; set; }
public bool CanManageProjects { get; set; }
public bool CanManageLedger { get; set; }
public bool CanModerateMotions { get; set; }
public decimal PatronWeight { get; set; }
public decimal ContributorWeight { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset? ApprovedAtUtc { get; set; }
public string Notes { get; set; } = string.Empty;
[NotMapped]
public decimal EffectiveVotingWeight => Math.Max(1m, PatronWeight + ContributorWeight);
public UserAccount UserAccount { get; set; } = null!;
}
public sealed class MembershipInvitation
{
public Guid Id { get; set; } = Guid.NewGuid();
public string GitHubLogin { get; set; } = string.Empty;
public string NormalizedGitHubLogin { get; set; } = string.Empty;
public Guid? IssuedByUserAccountId { get; set; }
public Guid? AcceptedByUserAccountId { get; set; }
public DateTimeOffset IssuedAtUtc { get; set; }
public DateTimeOffset? AcceptedAtUtc { get; set; }
public DateTimeOffset? RevokedAtUtc { get; set; }
public string Notes { get; set; } = string.Empty;
public UserAccount? IssuedByUserAccount { get; set; }
public UserAccount? AcceptedByUserAccount { get; set; }
}
public sealed class Project
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid OwnerUserAccountId { get; set; }
public string Slug { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public ProjectStatus Status { get; set; } = ProjectStatus.Active;
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public UserAccount OwnerUserAccount { get; set; } = null!;
public ICollection<WorkItem> WorkItems { get; set; } = [];
public ICollection<Motion> Motions { get; set; } = [];
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
}
public sealed class WorkItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Guid RequestedByUserAccountId { get; set; }
public Guid? AssignedToUserAccountId { get; set; }
public WorkItemSourceType SourceType { get; set; } = WorkItemSourceType.Internal;
public string ExternalSourceId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public WorkItemStatus Status { get; set; } = WorkItemStatus.Open;
public decimal EffortPoints { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public Project Project { get; set; } = null!;
public UserAccount RequestedByUserAccount { get; set; } = null!;
public UserAccount? AssignedToUserAccount { get; set; }
public ICollection<VolunteerClaim> VolunteerClaims { get; set; } = [];
public ICollection<Assignment> Assignments { get; set; } = [];
public ICollection<LedgerEntry> LedgerEntries { get; set; } = [];
}
public sealed class VolunteerClaim
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WorkItemId { get; set; }
public Guid UserAccountId { get; set; }
public VolunteerClaimStatus Status { get; set; } = VolunteerClaimStatus.Active;
public string Note { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; }
public WorkItem WorkItem { get; set; } = null!;
public UserAccount UserAccount { get; set; } = null!;
}
public sealed class Assignment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid WorkItemId { get; set; }
public Guid UserAccountId { get; set; }
public Guid AssignedByUserAccountId { get; set; }
public string Note { get; set; } = string.Empty;
public DateTimeOffset AssignedAtUtc { get; set; }
public WorkItem WorkItem { get; set; } = null!;
public UserAccount UserAccount { get; set; } = null!;
public UserAccount AssignedByUserAccount { get; set; } = null!;
}
public sealed class Motion
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? ProjectId { get; set; }
public Guid CreatedByUserAccountId { get; set; }
public MotionScope Scope { get; set; } = MotionScope.Project;
public MotionStatus Status { get; set; } = MotionStatus.Open;
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public decimal ApprovalThreshold { get; set; } = 0.60m;
public DateTimeOffset OpensAtUtc { get; set; }
public DateTimeOffset ClosesAtUtc { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public Project? Project { get; set; }
public UserAccount CreatedByUserAccount { get; set; } = null!;
public ICollection<Vote> Votes { get; set; } = [];
}
public sealed class Vote
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid MotionId { get; set; }
public Guid UserAccountId { get; set; }
public VoteChoice Choice { get; set; }
public decimal Weight { get; set; }
public string Comment { get; set; } = string.Empty;
public DateTimeOffset CastAtUtc { get; set; }
public Motion Motion { get; set; } = null!;
public UserAccount UserAccount { get; set; } = null!;
}
public sealed class LedgerEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserAccountId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? WorkItemId { get; set; }
public Guid CreatedByUserAccountId { get; set; }
public LedgerEntryType EntryType { get; set; } = LedgerEntryType.ContributionCredit;
public LedgerEntryStatus Status { get; set; } = LedgerEntryStatus.Draft;
public decimal Points { get; set; }
public decimal NominalAmount { get; set; }
public string Note { get; set; } = string.Empty;
public DateTimeOffset EffectiveAtUtc { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public UserAccount UserAccount { get; set; } = null!;
public UserAccount CreatedByUserAccount { get; set; } = null!;
public Project? Project { get; set; }
public WorkItem? WorkItem { get; set; }
}
public sealed class PayoutProposalBatch
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid CreatedByUserAccountId { get; set; }
public PayoutProposalBatchStatus Status { get; set; } = PayoutProposalBatchStatus.Draft;
public string Summary { get; set; } = string.Empty;
public DateTimeOffset CreatedAtUtc { get; set; }
public UserAccount CreatedByUserAccount { get; set; } = null!;
}
public sealed class AuditEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? ActorUserAccountId { get; set; }
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
public DateTimeOffset OccurredAtUtc { get; set; }
public UserAccount? ActorUserAccount { get; set; }
}

View file

@ -0,0 +1,27 @@
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authorization;
namespace Bifrost.Web.Features.Membership;
public sealed class ActiveMemberAuthorizationHandler(
ICurrentBifrostActorAccessor actorAccessor,
IHttpContextAccessor httpContextAccessor) : AuthorizationHandler<ActiveMemberRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ActiveMemberRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated != true)
{
return;
}
var cancellationToken = httpContextAccessor.HttpContext?.RequestAborted ?? CancellationToken.None;
var actor = await actorAccessor.GetAsync(cancellationToken);
if (actor.IsActiveMember)
{
context.Succeed(requirement);
}
}
}

View file

@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
namespace Bifrost.Web.Features.Membership;
public sealed class ActiveMemberRequirement : IAuthorizationRequirement
{
public const string PolicyName = "ActiveMember";
}
public static class GitHubAuthenticationDefaults
{
public const string AuthenticationScheme = "GitHub";
}
public static class BifrostClaimTypes
{
public const string GitHubLogin = "urn:bifrost:github-login";
public const string DisplayName = "urn:bifrost:display-name";
public const string AvatarUrl = "urn:bifrost:avatar-url";
}

View file

@ -0,0 +1,18 @@
namespace Bifrost.Web.Features.Membership;
public sealed class GitHubMembershipSynchronizationMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
MembershipSynchronizationService membershipSynchronizationService)
{
if (context.User.Identity?.IsAuthenticated == true)
{
await membershipSynchronizationService.SynchronizeAsync(
context.User,
context.RequestAborted);
}
await next(context);
}
}

View file

@ -0,0 +1,135 @@
using System.Security.Claims;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Membership;
public sealed class MembershipSynchronizationService(
BifrostDbContext dbContext,
IOptions<BootstrapOptions> bootstrapOptions,
TimeProvider timeProvider)
{
public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken)
{
if (!TryReadGitHubIdentity(principal, out var gitHubUserId, out var gitHubLogin))
{
return;
}
var displayName = principal.FindFirstValue(BifrostClaimTypes.DisplayName);
var avatarUrl = principal.FindFirstValue(BifrostClaimTypes.AvatarUrl);
var normalizedLogin = Normalize(gitHubLogin);
var now = timeProvider.GetUtcNow();
var userAccount = await dbContext.UserAccounts
.Include(x => x.MemberProfile)
.Include(x => x.Membership)
.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
var invitation = await dbContext.MembershipInvitations
.SingleOrDefaultAsync(
x => x.NormalizedGitHubLogin == normalizedLogin && x.RevokedAtUtc == null,
cancellationToken);
if (userAccount is null)
{
userAccount = new UserAccount
{
GitHubUserId = gitHubUserId,
GitHubLogin = gitHubLogin,
NormalizedGitHubLogin = normalizedLogin,
DisplayName = displayName ?? gitHubLogin,
AvatarUrl = avatarUrl ?? string.Empty,
CreatedAtUtc = now,
LastSeenAtUtc = now,
MemberProfile = new MemberProfile
{
Headline = "New member record",
UpdatedAtUtc = now
},
Membership = new Bifrost.Web.Domain.Membership
{
Status = invitation is null ? MembershipStatus.Authenticated : MembershipStatus.PendingApproval,
CreatedAtUtc = now
}
};
dbContext.UserAccounts.Add(userAccount);
}
else
{
userAccount.GitHubLogin = gitHubLogin;
userAccount.NormalizedGitHubLogin = normalizedLogin;
userAccount.DisplayName = string.IsNullOrWhiteSpace(displayName) ? gitHubLogin : displayName;
userAccount.AvatarUrl = avatarUrl ?? userAccount.AvatarUrl;
userAccount.LastSeenAtUtc = now;
userAccount.MemberProfile ??= new MemberProfile
{
UserAccountId = userAccount.Id,
Headline = "Imported from GitHub sign-in",
UpdatedAtUtc = now
};
userAccount.Membership ??= new Bifrost.Web.Domain.Membership
{
UserAccountId = userAccount.Id,
CreatedAtUtc = now
};
if (invitation is not null && userAccount.Membership.Status == MembershipStatus.Authenticated)
{
userAccount.Membership.Status = MembershipStatus.PendingApproval;
}
}
if (invitation is not null && invitation.AcceptedByUserAccountId is null)
{
invitation.AcceptedByUserAccountId = userAccount.Id;
invitation.AcceptedAtUtc = now;
}
if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value))
{
userAccount.Membership!.Status = MembershipStatus.Active;
userAccount.Membership.IsPlatformAdmin = true;
userAccount.Membership.CanManageProjects = true;
userAccount.Membership.CanManageLedger = true;
userAccount.Membership.CanModerateMotions = true;
userAccount.Membership.ApprovedAtUtc ??= now;
}
userAccount.MemberProfile!.UpdatedAtUtc = now;
await dbContext.SaveChangesAsync(cancellationToken);
}
private static bool TryReadGitHubIdentity(
ClaimsPrincipal principal,
out long gitHubUserId,
out string gitHubLogin)
{
gitHubLogin = principal.FindFirstValue(BifrostClaimTypes.GitHubLogin)
?? principal.FindFirstValue(ClaimTypes.Name)
?? string.Empty;
var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(idClaim, out gitHubUserId) || string.IsNullOrWhiteSpace(gitHubLogin))
{
gitHubUserId = 0;
return false;
}
return true;
}
private static string Normalize(string gitHubLogin) =>
gitHubLogin.Trim().ToUpperInvariant();
private static bool IsBootstrapAdmin(string gitHubLogin, BootstrapOptions options) =>
options.AdminGitHubLogins.Any(login =>
string.Equals(login, gitHubLogin, StringComparison.OrdinalIgnoreCase));
}

View file

@ -0,0 +1,28 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
namespace Bifrost.Web.Features.Shared;
public sealed class AuditTrailService(BifrostDbContext dbContext, TimeProvider timeProvider)
{
public Task RecordAsync(
Guid? actorUserAccountId,
string entityType,
Guid entityId,
string action,
string detail,
CancellationToken cancellationToken)
{
dbContext.AuditEvents.Add(new AuditEvent
{
ActorUserAccountId = actorUserAccountId,
EntityType = entityType,
EntityId = entityId.ToString(),
Action = action,
Detail = detail,
OccurredAtUtc = timeProvider.GetUtcNow()
});
return dbContext.SaveChangesAsync(cancellationToken);
}
}

View file

@ -0,0 +1,33 @@
using Bifrost.Web.Domain;
namespace Bifrost.Web.Features.Shared;
public sealed record CurrentBifrostActor(bool IsAuthenticated, UserAccount? UserAccount)
{
public static readonly CurrentBifrostActor Anonymous = new(false, null);
public Bifrost.Web.Domain.Membership? Membership => UserAccount?.Membership;
public string DisplayName =>
UserAccount?.DisplayName
?? UserAccount?.GitHubLogin
?? "Guest";
public bool IsActiveMember => Membership?.Status == MembershipStatus.Active;
public bool CanManageMembership => Membership?.IsPlatformAdmin == true;
public bool CanManageProjects =>
Membership?.IsPlatformAdmin == true ||
Membership?.CanManageProjects == true;
public bool CanManageLedger =>
Membership?.IsPlatformAdmin == true ||
Membership?.CanManageLedger == true;
public bool CanModerateMotions =>
Membership?.IsPlatformAdmin == true ||
Membership?.CanModerateMotions == true;
public decimal EffectiveVotingWeight => Membership?.EffectiveVotingWeight ?? 0m;
}

View file

@ -0,0 +1,56 @@
using System.Security.Claims;
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Shared;
public interface ICurrentBifrostActorAccessor
{
Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken);
}
public sealed class CurrentBifrostActorAccessor(
IHttpContextAccessor httpContextAccessor,
BifrostDbContext dbContext) : ICurrentBifrostActorAccessor
{
private const string CacheKey = "Bifrost.CurrentActor";
public async Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext?.Items.TryGetValue(CacheKey, out var cachedActor) == true &&
cachedActor is CurrentBifrostActor actor)
{
return actor;
}
if (httpContext?.User.Identity?.IsAuthenticated != true)
{
return Cache(httpContext, CurrentBifrostActor.Anonymous);
}
var gitHubId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (!long.TryParse(gitHubId, out var gitHubUserId))
{
return Cache(httpContext, CurrentBifrostActor.Anonymous);
}
var userAccount = await dbContext.UserAccounts
.AsNoTracking()
.Include(x => x.MemberProfile)
.Include(x => x.Membership)
.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken);
return Cache(httpContext, new CurrentBifrostActor(true, userAccount));
}
private static CurrentBifrostActor Cache(HttpContext? httpContext, CurrentBifrostActor actor)
{
if (httpContext is not null)
{
httpContext.Items[CacheKey] = actor;
}
return actor;
}
}

View file

@ -0,0 +1,92 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Shared;
public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimeProvider timeProvider)
{
public async Task<DashboardSnapshot> GetAsync(Guid? currentUserAccountId, CancellationToken cancellationToken)
{
var recentWorkItems = await dbContext.WorkItems
.AsNoTracking()
.Include(x => x.Project)
.OrderByDescending(x => x.UpdatedAtUtc)
.Take(5)
.Select(x => new DashboardWorkItem(
x.Title,
x.Project.Name,
x.Status.ToString(),
x.Id))
.ToListAsync(cancellationToken);
var openMotions = await dbContext.Motions
.AsNoTracking()
.Include(x => x.Votes)
.OrderBy(x => x.ClosesAtUtc)
.Take(5)
.Select(x => new DashboardMotion(
x.Title,
x.Scope.ToString(),
x.Votes.Sum(v => v.Choice == VoteChoice.For ? v.Weight : 0),
x.Votes.Sum(v => v.Choice == VoteChoice.Against ? v.Weight : 0),
x.ClosesAtUtc,
x.Id))
.ToListAsync(cancellationToken);
var outstandingApprovals = await dbContext.Memberships
.AsNoTracking()
.CountAsync(x => x.Status == MembershipStatus.PendingApproval, cancellationToken);
var assignedToMe = currentUserAccountId is null
? 0
: await dbContext.WorkItems
.AsNoTracking()
.CountAsync(
x => x.AssignedToUserAccountId == currentUserAccountId &&
x.Status != WorkItemStatus.Done &&
x.Status != WorkItemStatus.Archived,
cancellationToken);
var payoutPreviewNominal = await dbContext.LedgerEntries
.AsNoTracking()
.Where(x => x.Status == LedgerEntryStatus.Approved)
.SumAsync(x => x.NominalAmount, cancellationToken);
return new DashboardSnapshot(
ActiveMembers: await dbContext.Memberships.AsNoTracking().CountAsync(x => x.Status == MembershipStatus.Active, cancellationToken),
OpenProjects: await dbContext.Projects.AsNoTracking().CountAsync(x => x.Status == ProjectStatus.Active, cancellationToken),
OpenWorkItems: await dbContext.WorkItems.AsNoTracking().CountAsync(x => x.Status == WorkItemStatus.Open || x.Status == WorkItemStatus.Claimed || x.Status == WorkItemStatus.InProgress, cancellationToken),
OpenMotions: await dbContext.Motions.AsNoTracking().CountAsync(x => x.Status == MotionStatus.Open && x.ClosesAtUtc >= timeProvider.GetUtcNow(), cancellationToken),
PendingMembershipApprovals: outstandingApprovals,
AssignedToCurrentMember: assignedToMe,
ApprovedNominalPayoutValue: payoutPreviewNominal,
RecentWorkItems: recentWorkItems,
OpenMotionHighlights: openMotions);
}
}
public sealed record DashboardSnapshot(
int ActiveMembers,
int OpenProjects,
int OpenWorkItems,
int OpenMotions,
int PendingMembershipApprovals,
int AssignedToCurrentMember,
decimal ApprovedNominalPayoutValue,
IReadOnlyList<DashboardWorkItem> RecentWorkItems,
IReadOnlyList<DashboardMotion> OpenMotionHighlights);
public sealed record DashboardWorkItem(
string Title,
string ProjectName,
string Status,
Guid WorkItemId);
public sealed record DashboardMotion(
string Title,
string Scope,
decimal VotesFor,
decimal VotesAgainst,
DateTimeOffset ClosesAtUtc,
Guid MotionId);

View file

@ -0,0 +1,32 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Bifrost.Web.Features.Shared;
public static partial class SlugGenerator
{
public static string Create(string rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return Guid.NewGuid().ToString("N")[..12];
}
var sanitized = rawValue.Trim().ToLowerInvariant();
sanitized = NonAlphaNumeric().Replace(sanitized, "-");
sanitized = MultipleHyphens().Replace(sanitized, "-").Trim('-');
if (string.IsNullOrWhiteSpace(sanitized))
{
sanitized = Convert.ToHexString(Encoding.UTF8.GetBytes(rawValue)).ToLowerInvariant()[..12];
}
return sanitized;
}
[GeneratedRegex("[^a-z0-9]+")]
private static partial Regex NonAlphaNumeric();
[GeneratedRegex("-{2,}")]
private static partial Regex MultipleHyphens();
}

View file

@ -0,0 +1,92 @@
@page "/App"
@model Bifrost.Web.Pages.App.IndexModel
@{
ViewData["Title"] = "Member console";
}
<section class="panel">
<div class="panel-header">
<div>
<span class="eyebrow">Member console</span>
<h1>Operational picture for @Model.Actor.DisplayName</h1>
<p class="panel-intro">
This view ties together the slices we care about first: who can participate,
what work is moving, what motions are open, and what ledger value is accruing.
</p>
</div>
<button class="button secondary"
hx-get="@Url.Page("/App/Index", "Summary")"
hx-target="#summary-cards"
hx-swap="outerHTML">
Refresh snapshot
</button>
</div>
<div id="summary-cards">
<partial name="_SummaryCards" model="Model.Snapshot" />
</div>
</section>
<section class="panel-grid">
<article class="panel">
<div class="panel-header">
<div>
<h2>Recent work</h2>
<p class="muted">Latest work item movement across projects.</p>
</div>
<a class="button ghost" asp-page="/App/WorkItems/Index">Manage work items</a>
</div>
@if (Model.Snapshot.RecentWorkItems.Count == 0)
{
<div class="empty-state">No work items exist yet.</div>
}
else
{
<div class="list-grid">
@foreach (var item in Model.Snapshot.RecentWorkItems)
{
<div class="status-banner">
<strong>@item.Title</strong>
<span class="muted">@item.ProjectName</span>
<div class="inline-actions">
<span class="pill">@item.Status</span>
</div>
</div>
}
</div>
}
</article>
<article class="panel">
<div class="panel-header">
<div>
<h2>Open motions</h2>
<p class="muted">A quick read on governance traffic and current vote pressure.</p>
</div>
<a class="button ghost" asp-page="/App/Motions/Index">Review motions</a>
</div>
@if (Model.Snapshot.OpenMotionHighlights.Count == 0)
{
<div class="empty-state">No active motions yet.</div>
}
else
{
<div class="list-grid">
@foreach (var motion in Model.Snapshot.OpenMotionHighlights)
{
<div class="motion-card">
<h3>@motion.Title</h3>
<div class="inline-actions">
<span class="pill">@motion.Scope</span>
<span class="pill success">For @motion.VotesFor</span>
<span class="pill danger">Against @motion.VotesAgainst</span>
</div>
<p class="muted">Closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC</p>
</div>
}
</div>
}
</article>
</section>

View file

@ -0,0 +1,28 @@
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Bifrost.Web.Pages.App;
public sealed class IndexModel(
ICurrentBifrostActorAccessor actorAccessor,
DashboardSnapshotService snapshotService) : PageModel
{
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public DashboardSnapshot Snapshot { get; private set; } =
new(0, 0, 0, 0, 0, 0, 0m, [], []);
public async Task OnGetAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken);
}
public async Task<PartialViewResult> OnGetSummaryAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken);
return Partial("_SummaryCards", Snapshot);
}
}

View file

@ -0,0 +1,154 @@
@page
@model Bifrost.Web.Pages.App.Ledger.IndexModel
@{
ViewData["Title"] = "Ledger";
}
<section class="panel-grid">
<article class="panel">
<span class="eyebrow">Ledger</span>
<h1>Contribution and payout accounting</h1>
<p class="panel-intro">
The app does not pay anyone directly in v1. It records the accounting facts that make
payout review possible later, with explicit status and enough context to audit the trail.
</p>
<form method="post" asp-page-handler="Create">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.UserAccountId"></label>
<select asp-for="Input.UserAccountId" asp-items="Model.MemberOptions"></select>
<span asp-validation-for="Input.UserAccountId"></span>
</div>
<div class="field">
<label asp-for="Input.EntryType"></label>
<select asp-for="Input.EntryType" asp-items="Html.GetEnumSelectList<LedgerEntryType>()"></select>
<span asp-validation-for="Input.EntryType"></span>
</div>
<div class="field">
<label asp-for="Input.Status"></label>
<select asp-for="Input.Status" asp-items="Html.GetEnumSelectList<LedgerEntryStatus>()"></select>
<span asp-validation-for="Input.Status"></span>
</div>
<div class="field">
<label asp-for="Input.ProjectId"></label>
<select asp-for="Input.ProjectId" asp-items="Model.ProjectOptions">
<option value="">No project</option>
</select>
<span asp-validation-for="Input.ProjectId"></span>
</div>
<div class="field">
<label asp-for="Input.WorkItemId"></label>
<select asp-for="Input.WorkItemId" asp-items="Model.WorkItemOptions">
<option value="">No work item</option>
</select>
<span asp-validation-for="Input.WorkItemId"></span>
</div>
<div class="field">
<label asp-for="Input.Points"></label>
<input asp-for="Input.Points" />
<span asp-validation-for="Input.Points"></span>
</div>
<div class="field">
<label asp-for="Input.NominalAmount"></label>
<input asp-for="Input.NominalAmount" />
<span asp-validation-for="Input.NominalAmount"></span>
</div>
</div>
<div class="field">
<label asp-for="Input.Note"></label>
<textarea asp-for="Input.Note"></textarea>
<span asp-validation-for="Input.Note"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Record entry</button>
</div>
</form>
</article>
<article class="panel">
<div class="panel-header">
<div>
<h2>Payout proposal preview</h2>
<p class="muted">Approved nominal values grouped by member.</p>
</div>
</div>
@if (Model.PayoutPreview.Count == 0)
{
<div class="empty-state">No approved ledger entries yet.</div>
}
else
{
<div class="preview-grid">
@foreach (var preview in Model.PayoutPreview)
{
<article class="preview-card">
<h3>@preview.MemberName</h3>
<span class="pill">@preview.Points.ToString("0.##") pts</span>
<p class="muted">Nominal value @preview.NominalAmount.ToString("0.##")</p>
</article>
}
</div>
}
</article>
</section>
<section class="table-panel" style="margin-top: 1.5rem;">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Recent ledger events</h2>
<p class="table-meta">@Model.LedgerEntries.Count entries in the current view.</p>
</div>
</div>
@if (Model.LedgerEntries.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No ledger activity yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>Member</th>
<th>Type</th>
<th>Value</th>
<th>Status</th>
<th>Context</th>
</tr>
</thead>
<tbody>
@foreach (var entry in Model.LedgerEntries)
{
<tr>
<td>@entry.UserAccount.DisplayName</td>
<td>
<span class="pill">@entry.EntryType</span>
</td>
<td>
@entry.Points.ToString("0.##") pts<br />
<span class="muted">@entry.NominalAmount.ToString("0.##") nominal</span>
</td>
<td><span class="pill">@entry.Status</span></td>
<td>
@entry.Project?.Name
@if (entry.WorkItem is not null)
{
<div class="muted">@entry.WorkItem.Title</div>
}
<div class="muted">@entry.Note</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -0,0 +1,152 @@
using System.ComponentModel.DataAnnotations;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Pages.App.Ledger;
public sealed class IndexModel(
BifrostDbContext dbContext,
ICurrentBifrostActorAccessor actorAccessor,
AuditTrailService auditTrailService,
TimeProvider timeProvider) : PageModel
{
[BindProperty]
public CreateLedgerEntryInput Input { get; set; } = new();
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<SelectListItem> MemberOptions { get; private set; } = [];
public IReadOnlyList<SelectListItem> ProjectOptions { get; private set; } = [];
public IReadOnlyList<SelectListItem> WorkItemOptions { get; private set; } = [];
public IReadOnlyList<LedgerEntry> LedgerEntries { get; private set; } = [];
public IReadOnlyList<PayoutPreviewItem> PayoutPreview { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
await LoadAsync(cancellationToken);
}
public async Task<IActionResult> OnPostCreateAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!ModelState.IsValid)
{
await LoadAsync(cancellationToken);
return Page();
}
var now = timeProvider.GetUtcNow();
var entry = new LedgerEntry
{
UserAccountId = Input.UserAccountId,
ProjectId = Input.ProjectId,
WorkItemId = Input.WorkItemId,
CreatedByUserAccountId = Actor.UserAccount!.Id,
EntryType = Input.EntryType,
Status = Input.Status,
Points = Input.Points,
NominalAmount = Input.NominalAmount,
Note = Input.Note.Trim(),
EffectiveAtUtc = now,
CreatedAtUtc = now
};
dbContext.LedgerEntries.Add(entry);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount.Id,
nameof(LedgerEntry),
entry.Id,
"ledger-entry.created",
$"Created {entry.EntryType} ledger entry.",
cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
MemberOptions = await dbContext.UserAccounts
.AsNoTracking()
.Include(x => x.Membership)
.Where(x => x.Membership != null && x.Membership.Status == MembershipStatus.Active)
.OrderBy(x => x.DisplayName)
.Select(x => new SelectListItem(x.DisplayName, x.Id.ToString()))
.ToListAsync(cancellationToken);
ProjectOptions = await dbContext.Projects
.AsNoTracking()
.OrderBy(x => x.Name)
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToListAsync(cancellationToken);
WorkItemOptions = await dbContext.WorkItems
.AsNoTracking()
.OrderByDescending(x => x.UpdatedAtUtc)
.Select(x => new SelectListItem(x.Title, x.Id.ToString()))
.ToListAsync(cancellationToken);
LedgerEntries = await dbContext.LedgerEntries
.AsNoTracking()
.Include(x => x.UserAccount)
.Include(x => x.Project)
.Include(x => x.WorkItem)
.OrderByDescending(x => x.CreatedAtUtc)
.Take(20)
.ToListAsync(cancellationToken);
PayoutPreview = await dbContext.LedgerEntries
.AsNoTracking()
.Where(x => x.Status == LedgerEntryStatus.Approved)
.Include(x => x.UserAccount)
.GroupBy(x => x.UserAccount.DisplayName)
.Select(group => new PayoutPreviewItem(
group.Key,
group.Sum(x => x.Points),
group.Sum(x => x.NominalAmount)))
.OrderByDescending(x => x.NominalAmount)
.ToListAsync(cancellationToken);
}
public sealed class CreateLedgerEntryInput
{
[Required]
[Display(Name = "Member")]
public Guid UserAccountId { get; set; }
[Display(Name = "Project")]
public Guid? ProjectId { get; set; }
[Display(Name = "Work item")]
public Guid? WorkItemId { get; set; }
[Display(Name = "Entry type")]
public LedgerEntryType EntryType { get; set; } = LedgerEntryType.ContributionCredit;
public LedgerEntryStatus Status { get; set; } = LedgerEntryStatus.Draft;
[Range(0, 100000)]
public decimal Points { get; set; }
[Display(Name = "Nominal amount")]
[Range(0, 100000000)]
public decimal NominalAmount { get; set; }
[Required, StringLength(3000)]
public string Note { get; set; } = string.Empty;
}
public sealed record PayoutPreviewItem(string MemberName, decimal Points, decimal NominalAmount);
}

View file

@ -0,0 +1,155 @@
@page
@model Bifrost.Web.Pages.App.Members.IndexModel
@{
ViewData["Title"] = "Members";
}
<section class="panel-grid">
<article class="panel">
<span class="eyebrow">Members</span>
<h1>Invite and approval gate</h1>
<p class="panel-intro">
Invite-first onboarding keeps the alpha intentionally small. Approval remains human
because trust and contributor accountability are product concerns, not just auth concerns.
</p>
@if (Model.Actor.CanManageMembership)
{
<form method="post" asp-page-handler="Invite">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.GitHubLogin"></label>
<input asp-for="Input.GitHubLogin" />
<span asp-validation-for="Input.GitHubLogin"></span>
</div>
<div class="field">
<label asp-for="Input.Notes"></label>
<input asp-for="Input.Notes" />
<span asp-validation-for="Input.Notes"></span>
</div>
</div>
<div class="panel-actions">
<button class="button" type="submit">Issue invite</button>
</div>
</form>
}
else
{
<div class="callout warning">
Only platform admins can issue invites or approve members.
</div>
}
</article>
<article class="table-panel">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Current member records</h2>
<p class="table-meta">@Model.Users.Count authenticated GitHub identities tracked.</p>
</div>
</div>
@if (Model.Users.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No member records yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>Account</th>
<th>Status</th>
<th>Permissions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in Model.Users)
{
<tr>
<td>
<strong>@user.DisplayName</strong><br />
<span class="muted">@user.GitHubLogin</span>
</td>
<td>
<span class="pill">@user.Membership!.Status</span>
</td>
<td>
@(user.Membership.IsPlatformAdmin ? "Platform admin" : "Member")
<div class="muted">
Projects @(user.Membership.CanManageProjects ? "on" : "off"),
ledger @(user.Membership.CanManageLedger ? "on" : "off")
</div>
</td>
<td>
@if (Model.Actor.CanManageMembership && user.Membership.Status != MembershipStatus.Active)
{
<form method="post" asp-page-handler="Approve">
<input type="hidden" name="userAccountId" value="@user.Id" />
<button class="button secondary" type="submit">Approve</button>
</form>
}
else
{
<span class="muted">No action</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</article>
</section>
<section class="table-panel" style="margin-top: 1.5rem;">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Outstanding invites</h2>
<p class="table-meta">@Model.Invitations.Count active invitations tracked.</p>
</div>
</div>
@if (Model.Invitations.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No invitations yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>GitHub login</th>
<th>Issued</th>
<th>Accepted</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
@foreach (var invitation in Model.Invitations)
{
<tr>
<td>@invitation.GitHubLogin</td>
<td>@invitation.IssuedAtUtc.ToString("dd MMM yyyy HH:mm") UTC</td>
<td>
@(invitation.AcceptedAtUtc is null
? "Waiting for sign-in"
: invitation.AcceptedAtUtc.Value.ToString("dd MMM yyyy HH:mm") + " UTC")
</td>
<td>@invitation.Notes</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -0,0 +1,142 @@
using System.ComponentModel.DataAnnotations;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Pages.App.Members;
public sealed class IndexModel(
BifrostDbContext dbContext,
ICurrentBifrostActorAccessor actorAccessor,
AuditTrailService auditTrailService,
TimeProvider timeProvider) : PageModel
{
[BindProperty]
public InviteMemberInput Input { get; set; } = new();
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<UserAccount> Users { get; private set; } = [];
public IReadOnlyList<MembershipInvitation> Invitations { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
await LoadAsync(cancellationToken);
}
public async Task<IActionResult> OnPostInviteAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageMembership)
{
return Forbid();
}
if (!ModelState.IsValid)
{
await LoadAsync(cancellationToken);
return Page();
}
var normalizedLogin = Input.GitHubLogin.Trim().ToUpperInvariant();
var invitation = await dbContext.MembershipInvitations
.SingleOrDefaultAsync(x => x.NormalizedGitHubLogin == normalizedLogin, cancellationToken);
if (invitation is null)
{
invitation = new MembershipInvitation
{
GitHubLogin = Input.GitHubLogin.Trim(),
NormalizedGitHubLogin = normalizedLogin,
IssuedByUserAccountId = Actor.UserAccount!.Id,
IssuedAtUtc = timeProvider.GetUtcNow(),
Notes = Input.Notes.Trim()
};
dbContext.MembershipInvitations.Add(invitation);
}
else
{
invitation.RevokedAtUtc = null;
invitation.Notes = Input.Notes.Trim();
}
var existingUser = await dbContext.UserAccounts
.Include(x => x.Membership)
.SingleOrDefaultAsync(x => x.NormalizedGitHubLogin == normalizedLogin, cancellationToken);
if (existingUser?.Membership is not null && existingUser.Membership.Status == MembershipStatus.Authenticated)
{
existingUser.Membership.Status = MembershipStatus.PendingApproval;
}
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount!.Id,
nameof(MembershipInvitation),
invitation.Id,
"membership.invited",
$"Issued invite for {invitation.GitHubLogin}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostApproveAsync(Guid userAccountId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!Actor.CanManageMembership)
{
return Forbid();
}
var user = await dbContext.UserAccounts
.Include(x => x.Membership)
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
user.Membership!.Status = MembershipStatus.Active;
user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount!.Id,
nameof(Membership),
user.Membership.Id,
"membership.approved",
$"Approved {user.DisplayName} as an active member.",
cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
Users = await dbContext.UserAccounts
.AsNoTracking()
.Include(x => x.Membership)
.OrderBy(x => x.DisplayName)
.ToListAsync(cancellationToken);
Invitations = await dbContext.MembershipInvitations
.AsNoTracking()
.Where(x => x.RevokedAtUtc == null)
.OrderByDescending(x => x.IssuedAtUtc)
.ToListAsync(cancellationToken);
}
public sealed class InviteMemberInput
{
[Required, StringLength(120)]
[Display(Name = "GitHub login")]
public string GitHubLogin { get; set; } = string.Empty;
[StringLength(500)]
public string Notes { get; set; } = string.Empty;
}
}

View file

@ -0,0 +1,107 @@
@page
@model Bifrost.Web.Pages.App.Motions.IndexModel
@{
ViewData["Title"] = "Motions";
}
<section class="panel-grid">
<article class="panel">
<span class="eyebrow">Governance</span>
<h1>Open motions and voting</h1>
<p class="panel-intro">
Motions stay app-native here: no token theater, just explicit thresholds,
weighted votes, and an auditable trail of who changed what.
</p>
<form method="post" asp-page-handler="Create">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.Scope"></label>
<select asp-for="Input.Scope" asp-items="Html.GetEnumSelectList<MotionScope>()"></select>
<span asp-validation-for="Input.Scope"></span>
</div>
<div class="field">
<label asp-for="Input.ProjectId"></label>
<select asp-for="Input.ProjectId" asp-items="Model.ProjectOptions">
<option value="">No project scope</option>
</select>
<span asp-validation-for="Input.ProjectId"></span>
</div>
<div class="field">
<label asp-for="Input.ClosesAtUtc"></label>
<input asp-for="Input.ClosesAtUtc" />
<span asp-validation-for="Input.ClosesAtUtc"></span>
</div>
</div>
<div class="field">
<label asp-for="Input.Title"></label>
<input asp-for="Input.Title" />
<span asp-validation-for="Input.Title"></span>
</div>
<div class="field">
<label asp-for="Input.Summary"></label>
<textarea asp-for="Input.Summary"></textarea>
<span asp-validation-for="Input.Summary"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Open motion</button>
</div>
</form>
</article>
<article class="panel">
<div class="panel-header">
<div>
<h2>Vote ledger</h2>
<p class="muted">Your current effective weight is @Model.Actor.EffectiveVotingWeight.</p>
</div>
</div>
@if (Model.Motions.Count == 0)
{
<div class="empty-state">No motions yet.</div>
}
else
{
<div class="motion-grid">
@foreach (var motion in Model.Motions)
{
<article class="motion-card">
<div class="inline-actions">
<span class="pill">@motion.Scope</span>
<span class="pill @motion.StatusTone">@motion.StatusLabel</span>
</div>
<h3>@motion.Title</h3>
<p class="muted">@motion.Summary</p>
<p class="muted">
@motion.ProjectName
· closes @motion.ClosesAtUtc.ToString("dd MMM yyyy HH:mm") UTC
</p>
<div class="inline-actions">
<span class="pill success">For @motion.VotesFor</span>
<span class="pill danger">Against @motion.VotesAgainst</span>
<span class="pill">Abstain @motion.VotesAbstain</span>
</div>
<div class="vote-strip">
@foreach (var choice in Enum.GetValues<VoteChoice>())
{
<form method="post" asp-page-handler="Vote">
<input type="hidden" name="motionId" value="@motion.Id" />
<input type="hidden" name="choice" value="@choice" />
<button class="button @(motion.CurrentUserVote == choice ? "warm" : "secondary")" type="submit">
Vote @choice
</button>
</form>
}
</div>
</article>
}
</div>
}
</article>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -0,0 +1,222 @@
using System.ComponentModel.DataAnnotations;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Pages.App.Motions;
public sealed class IndexModel(
BifrostDbContext dbContext,
ICurrentBifrostActorAccessor actorAccessor,
AuditTrailService auditTrailService,
TimeProvider timeProvider) : PageModel
{
[BindProperty]
public CreateMotionInput Input { get; set; } = new()
{
ClosesAtUtc = DateTimeOffset.UtcNow.AddDays(7)
};
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<SelectListItem> ProjectOptions { get; private set; } = [];
public IReadOnlyList<MotionListItem> Motions { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
await LoadAsync(cancellationToken);
}
public async Task<IActionResult> OnPostCreateAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (actorUser is null)
{
return Forbid();
}
if (!ModelState.IsValid)
{
await LoadAsync(cancellationToken);
return Page();
}
var now = timeProvider.GetUtcNow();
var motion = new Motion
{
ProjectId = Input.ProjectId,
CreatedByUserAccountId = actorUser.Id,
Scope = Input.Scope,
Title = Input.Title.Trim(),
Summary = Input.Summary.Trim(),
OpensAtUtc = now,
ClosesAtUtc = Input.ClosesAtUtc.ToUniversalTime(),
CreatedAtUtc = now
};
dbContext.Motions.Add(motion);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
actorUser.Id,
nameof(Motion),
motion.Id,
"motion.created",
$"Opened motion {motion.Title}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostVoteAsync(Guid motionId, VoteChoice choice, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (actorUser is null)
{
return Forbid();
}
var motion = await dbContext.Motions
.Include(x => x.Votes)
.SingleAsync(x => x.Id == motionId, cancellationToken);
if (motion.Status != MotionStatus.Open)
{
return RedirectToPage();
}
var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id);
if (vote is null)
{
dbContext.Votes.Add(new Vote
{
MotionId = motionId,
UserAccountId = actorUser.Id,
Choice = choice,
Weight = Actor.EffectiveVotingWeight,
CastAtUtc = timeProvider.GetUtcNow()
});
}
else
{
vote.Choice = choice;
vote.Weight = Actor.EffectiveVotingWeight;
vote.CastAtUtc = timeProvider.GetUtcNow();
}
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
actorUser.Id,
nameof(Motion),
motion.Id,
"motion.voted",
$"{Actor.DisplayName} voted {choice} on {motion.Title}.",
cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
ProjectOptions = await dbContext.Projects
.AsNoTracking()
.OrderBy(x => x.Name)
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToListAsync(cancellationToken);
var motions = await dbContext.Motions
.AsNoTracking()
.Include(x => x.Project)
.Include(x => x.Votes)
.OrderBy(x => x.ClosesAtUtc)
.ToListAsync(cancellationToken);
Motions = motions.Select(x => new MotionListItem(
x.Id,
x.Title,
x.Summary,
x.Project?.Name ?? "Management-wide",
x.Scope.ToString(),
ResolveStatusLabel(x, timeProvider.GetUtcNow()),
ResolveStatusTone(x, timeProvider.GetUtcNow()),
x.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight),
x.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight),
x.Votes.Where(v => v.Choice == VoteChoice.Abstain).Sum(v => v.Weight),
x.Votes.Where(v => v.UserAccountId == Actor.UserAccount?.Id).Select(v => (VoteChoice?)v.Choice).FirstOrDefault(),
x.ClosesAtUtc)).ToList();
}
private static string ResolveStatusLabel(Motion motion, DateTimeOffset now)
{
if (motion.Status != MotionStatus.Open)
{
return motion.Status.ToString();
}
if (motion.ClosesAtUtc >= now)
{
return "Open";
}
var votesFor = motion.Votes.Where(v => v.Choice == VoteChoice.For).Sum(v => v.Weight);
var votesAgainst = motion.Votes.Where(v => v.Choice == VoteChoice.Against).Sum(v => v.Weight);
var total = votesFor + votesAgainst;
if (total <= 0)
{
return "Closed";
}
return votesFor / total >= motion.ApprovalThreshold ? "Passed" : "Failed";
}
private static string ResolveStatusTone(Motion motion, DateTimeOffset now) =>
ResolveStatusLabel(motion, now) switch
{
"Passed" => "success",
"Failed" => "danger",
"Open" => string.Empty,
_ => "warning"
};
public sealed class CreateMotionInput
{
[Display(Name = "Scope")]
public MotionScope Scope { get; set; } = MotionScope.Project;
[Display(Name = "Project")]
public Guid? ProjectId { get; set; }
[Required, StringLength(180)]
public string Title { get; set; } = string.Empty;
[Required, StringLength(3000)]
public string Summary { get; set; } = string.Empty;
[Display(Name = "Close at (UTC)")]
public DateTimeOffset ClosesAtUtc { get; set; }
}
public sealed record MotionListItem(
Guid Id,
string Title,
string Summary,
string ProjectName,
string Scope,
string StatusLabel,
string StatusTone,
decimal VotesFor,
decimal VotesAgainst,
decimal VotesAbstain,
VoteChoice? CurrentUserVote,
DateTimeOffset ClosesAtUtc);
}

View file

@ -0,0 +1,89 @@
@page
@model Bifrost.Web.Pages.App.Projects.IndexModel
@{
ViewData["Title"] = "Projects";
}
<section class="panel-grid">
<article class="panel">
<span class="eyebrow">Projects</span>
<h1>Project containers</h1>
<p class="panel-intro">
Projects are the anchor for work items, motions, and ledger entries.
Keeping them explicit is what lets the rest of the system stay legible.
</p>
<form method="post" asp-page-handler="Create">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.Name"></label>
<input asp-for="Input.Name" />
<span asp-validation-for="Input.Name"></span>
</div>
<div class="field">
<label asp-for="Input.Slug"></label>
<input asp-for="Input.Slug" />
<span class="hint">Leave blank to derive from the name.</span>
<span asp-validation-for="Input.Slug"></span>
</div>
</div>
<div class="field">
<label asp-for="Input.Summary"></label>
<textarea asp-for="Input.Summary"></textarea>
<span asp-validation-for="Input.Summary"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Create project</button>
</div>
</form>
</article>
<article class="table-panel">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Current projects</h2>
<p class="table-meta">@Model.Projects.Count project containers tracked.</p>
</div>
</div>
@if (Model.Projects.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No projects yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Owner</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
@foreach (var project in Model.Projects)
{
<tr>
<td>
<strong>@project.Name</strong><br />
<span class="muted">@project.Slug</span>
<p class="muted">@project.Summary</p>
</td>
<td><span class="pill">@project.Status</span></td>
<td>@project.OwnerUserAccount.DisplayName</td>
<td>@project.UpdatedAtUtc.ToString("dd MMM yyyy")</td>
</tr>
}
</tbody>
</table>
</div>
}
</article>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -0,0 +1,94 @@
using System.ComponentModel.DataAnnotations;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Pages.App.Projects;
public sealed class IndexModel(
BifrostDbContext dbContext,
ICurrentBifrostActorAccessor actorAccessor,
AuditTrailService auditTrailService,
TimeProvider timeProvider) : PageModel
{
[BindProperty]
public CreateProjectInput Input { get; set; } = new();
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<Project> Projects { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
await LoadAsync(cancellationToken);
}
public async Task<IActionResult> OnPostCreateAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (!ModelState.IsValid)
{
await LoadAsync(cancellationToken);
return Page();
}
var slug = SlugGenerator.Create(string.IsNullOrWhiteSpace(Input.Slug) ? Input.Name : Input.Slug);
var exists = await dbContext.Projects.AnyAsync(x => x.Slug == slug, cancellationToken);
if (exists)
{
ModelState.AddModelError(nameof(Input.Slug), "That project slug is already in use.");
await LoadAsync(cancellationToken);
return Page();
}
var now = timeProvider.GetUtcNow();
var project = new Project
{
OwnerUserAccountId = Actor.UserAccount!.Id,
Slug = slug,
Name = Input.Name.Trim(),
Summary = Input.Summary.Trim(),
CreatedAtUtc = now,
UpdatedAtUtc = now
};
dbContext.Projects.Add(project);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
Actor.UserAccount.Id,
nameof(Project),
project.Id,
"project.created",
$"Created project {project.Name}.",
cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
Projects = await dbContext.Projects
.AsNoTracking()
.Include(x => x.OwnerUserAccount)
.OrderBy(x => x.Name)
.ToListAsync(cancellationToken);
}
public sealed class CreateProjectInput
{
[Required, StringLength(180)]
public string Name { get; set; } = string.Empty;
[StringLength(120)]
public string Slug { get; set; } = string.Empty;
[Required, StringLength(2000)]
public string Summary { get; set; } = string.Empty;
}
}

View file

@ -0,0 +1,140 @@
@page
@model Bifrost.Web.Pages.App.WorkItems.IndexModel
@{
ViewData["Title"] = "Work items";
}
<section class="panel-grid">
<article class="panel">
<span class="eyebrow">Work items</span>
<h1>Internal and GitHub-backed work</h1>
<p class="panel-intro">
The goal here is one shared list with enough structure to support volunteering,
assignment, and later ledger attribution without turning task intake into ceremony.
</p>
<form method="post" asp-page-handler="Create">
<div asp-validation-summary="ModelOnly" class="validation-summary-errors"></div>
<div class="form-grid">
<div class="field">
<label asp-for="Input.ProjectId"></label>
<select asp-for="Input.ProjectId" asp-items="Model.ProjectOptions"></select>
<span asp-validation-for="Input.ProjectId"></span>
</div>
<div class="field">
<label asp-for="Input.Title"></label>
<input asp-for="Input.Title" />
<span asp-validation-for="Input.Title"></span>
</div>
<div class="field">
<label asp-for="Input.EffortPoints"></label>
<input asp-for="Input.EffortPoints" />
<span asp-validation-for="Input.EffortPoints"></span>
</div>
</div>
<div class="field">
<label asp-for="Input.Summary"></label>
<textarea asp-for="Input.Summary"></textarea>
<span asp-validation-for="Input.Summary"></span>
</div>
<div class="panel-actions">
<button class="button" type="submit">Add work item</button>
</div>
</form>
</article>
<article class="table-panel">
<div class="panel-header" style="padding: 1.25rem 1.25rem 0;">
<div>
<h2>Queue</h2>
<p class="table-meta">@Model.WorkItems.Count tracked work items.</p>
</div>
</div>
@if (Model.WorkItems.Count == 0)
{
<div class="empty-state" style="margin: 1.25rem;">No work items yet.</div>
}
else
{
<div class="table-shell">
<table>
<thead>
<tr>
<th>Work</th>
<th>Status</th>
<th>Claims</th>
<th>Assignment</th>
</tr>
</thead>
<tbody>
@foreach (var workItem in Model.WorkItems)
{
var hasClaim = workItem.VolunteerClaims.Any(x => x.UserAccountId == Model.Actor.UserAccount?.Id && x.Status == VolunteerClaimStatus.Active);
<tr>
<td>
<strong>@workItem.Title</strong><br />
<span class="muted">@workItem.Project.Name</span>
<p class="muted">@workItem.Summary</p>
</td>
<td>
<span class="pill">@workItem.Status</span><br />
<span class="muted">@workItem.EffortPoints.ToString("0.##") pts</span>
</td>
<td>
@if (workItem.VolunteerClaims.Count == 0)
{
<span class="muted">No claims yet.</span>
}
else
{
@foreach (var claim in workItem.VolunteerClaims)
{
<div>@claim.UserAccount.DisplayName <span class="muted">(@claim.Status)</span></div>
}
}
@if (!hasClaim)
{
<form method="post" asp-page-handler="Volunteer">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<button class="button ghost" type="submit">Volunteer</button>
</form>
}
</td>
<td>
@if (workItem.AssignedToUserAccount is not null)
{
<strong>@workItem.AssignedToUserAccount.DisplayName</strong>
}
else
{
<span class="muted">Unassigned</span>
}
@if (Model.Actor.CanManageProjects && workItem.VolunteerClaims.Any(x => x.Status == VolunteerClaimStatus.Active))
{
<form method="post" asp-page-handler="Assign" class="inline-actions">
<input type="hidden" name="workItemId" value="@workItem.Id" />
<select name="userAccountId">
@foreach (var claim in workItem.VolunteerClaims.Where(x => x.Status == VolunteerClaimStatus.Active))
{
<option value="@claim.UserAccountId">@claim.UserAccount.DisplayName</option>
}
</select>
<button class="button secondary" type="submit">Assign</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</article>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -0,0 +1,206 @@
using System.ComponentModel.DataAnnotations;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Pages.App.WorkItems;
public sealed class IndexModel(
BifrostDbContext dbContext,
ICurrentBifrostActorAccessor actorAccessor,
AuditTrailService auditTrailService,
TimeProvider timeProvider) : PageModel
{
[BindProperty]
public CreateWorkItemInput Input { get; set; } = new();
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public IReadOnlyList<SelectListItem> ProjectOptions { get; private set; } = [];
public IReadOnlyList<WorkItem> WorkItems { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
await LoadAsync(cancellationToken);
}
public async Task<IActionResult> OnPostCreateAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (actorUser is null)
{
return Forbid();
}
if (!ModelState.IsValid)
{
await LoadAsync(cancellationToken);
return Page();
}
var now = timeProvider.GetUtcNow();
var workItem = new WorkItem
{
ProjectId = Input.ProjectId,
RequestedByUserAccountId = actorUser.Id,
Title = Input.Title.Trim(),
Summary = Input.Summary.Trim(),
EffortPoints = Input.EffortPoints,
SourceType = WorkItemSourceType.Internal,
Status = WorkItemStatus.Open,
CreatedAtUtc = now,
UpdatedAtUtc = now
};
dbContext.WorkItems.Add(workItem);
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
actorUser.Id,
nameof(WorkItem),
workItem.Id,
"work-item.created",
$"Created work item {workItem.Title}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostVolunteerAsync(Guid workItemId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (actorUser is null)
{
return Forbid();
}
var workItem = await dbContext.WorkItems
.Include(x => x.VolunteerClaims)
.SingleAsync(x => x.Id == workItemId, cancellationToken);
var existingClaim = workItem.VolunteerClaims.SingleOrDefault(x => x.UserAccountId == actorUser.Id);
if (existingClaim is null)
{
dbContext.VolunteerClaims.Add(new VolunteerClaim
{
WorkItemId = workItemId,
UserAccountId = actorUser.Id,
CreatedAtUtc = timeProvider.GetUtcNow()
});
}
else
{
existingClaim.Status = VolunteerClaimStatus.Active;
}
if (workItem.Status == WorkItemStatus.Open)
{
workItem.Status = WorkItemStatus.Claimed;
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
}
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
actorUser.Id,
nameof(WorkItem),
workItem.Id,
"work-item.volunteered",
$"{Actor.DisplayName} volunteered for {workItem.Title}.",
cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostAssignAsync(Guid workItemId, Guid userAccountId, CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
var actorUser = Actor.UserAccount;
if (!Actor.CanManageProjects)
{
return Forbid();
}
if (actorUser is null)
{
return Forbid();
}
var workItem = await dbContext.WorkItems
.Include(x => x.VolunteerClaims)
.SingleAsync(x => x.Id == workItemId, cancellationToken);
workItem.AssignedToUserAccountId = userAccountId;
workItem.Status = WorkItemStatus.InProgress;
workItem.UpdatedAtUtc = timeProvider.GetUtcNow();
var claim = workItem.VolunteerClaims.SingleOrDefault(x => x.UserAccountId == userAccountId);
if (claim is not null)
{
claim.Status = VolunteerClaimStatus.Accepted;
}
dbContext.Assignments.Add(new Assignment
{
WorkItemId = workItemId,
UserAccountId = userAccountId,
AssignedByUserAccountId = actorUser.Id,
AssignedAtUtc = timeProvider.GetUtcNow()
});
await dbContext.SaveChangesAsync(cancellationToken);
await auditTrailService.RecordAsync(
actorUser.Id,
nameof(WorkItem),
workItem.Id,
"work-item.assigned",
$"Assigned work item {workItem.Title}.",
cancellationToken);
return RedirectToPage();
}
private async Task LoadAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
ProjectOptions = await dbContext.Projects
.AsNoTracking()
.OrderBy(x => x.Name)
.Select(x => new SelectListItem(x.Name, x.Id.ToString()))
.ToListAsync(cancellationToken);
WorkItems = await dbContext.WorkItems
.AsNoTracking()
.Include(x => x.Project)
.Include(x => x.AssignedToUserAccount)
.Include(x => x.VolunteerClaims)
.ThenInclude(x => x.UserAccount)
.OrderByDescending(x => x.UpdatedAtUtc)
.ToListAsync(cancellationToken);
}
public sealed class CreateWorkItemInput
{
[Required]
[Display(Name = "Project")]
public Guid ProjectId { get; set; }
[Required, StringLength(180)]
public string Title { get; set; } = string.Empty;
[Required, StringLength(2000)]
public string Summary { get; set; } = string.Empty;
[Range(0, 1000)]
[Display(Name = "Effort points")]
public decimal EffortPoints { get; set; } = 1m;
}
}

View file

@ -0,0 +1,32 @@
@model DashboardSnapshot
<div class="metric-grid" id="summary-cards">
<article class="metric-card">
<span>Active members</span>
<strong>@Model.ActiveMembers</strong>
</article>
<article class="metric-card">
<span>Open projects</span>
<strong>@Model.OpenProjects</strong>
</article>
<article class="metric-card">
<span>Open work items</span>
<strong>@Model.OpenWorkItems</strong>
</article>
<article class="metric-card">
<span>Open motions</span>
<strong>@Model.OpenMotions</strong>
</article>
<article class="metric-card">
<span>Pending approvals</span>
<strong>@Model.PendingMembershipApprovals</strong>
</article>
<article class="metric-card">
<span>Assigned to me</span>
<strong>@Model.AssignedToCurrentMember</strong>
</article>
<article class="metric-card">
<span>Approved nominal payout</span>
<strong>@Model.ApprovedNominalPayoutValue.ToString("0.##")</strong>
</article>
</div>

View file

@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View file

@ -0,0 +1,20 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Bifrost.Web.Pages;
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}

View file

@ -0,0 +1,67 @@
@page
@model IndexModel
@{
ViewData["Title"] = "Bifrost";
}
<section class="hero">
<div class="hero-copy">
<span class="eyebrow">GameCult member alpha</span>
<h1>Bifrost is the operational bridge between projects, people, motions, and ledger truth.</h1>
<p class="lede">
This first slice keeps the product boring on purpose: GitHub sign-in, membership gating,
app-native governance, work tracking, and internal accounting in one server-rendered system.
</p>
@if (Model.Auth == "github-not-configured")
{
<div class="callout warning">
GitHub OAuth is not configured yet. Add <code>GitHubOAuth:ClientId</code> and
<code>GitHubOAuth:ClientSecret</code> before using sign-in.
</div>
}
<div class="hero-actions">
@if (Model.Actor.IsActiveMember)
{
<a class="button" asp-page="/App/Index">Open member console</a>
}
else if (User.Identity?.IsAuthenticated == true)
{
<a class="button" asp-page="/Membership/Status">Check membership status</a>
}
else
{
<a class="button" href="/auth/sign-in">Sign in with GitHub</a>
}
<a class="button secondary" href="https://github.com/GameCult/Bifrost">View repository</a>
</div>
</div>
<div class="hero-panel">
<h2>Current milestone</h2>
<ul class="stack-list">
<li>GitHub OAuth sign-in</li>
<li>Invite and approval gating</li>
<li>Projects and member records</li>
<li>GitHub-backed and internal work items</li>
<li>Motions, votes, and ledger activity</li>
</ul>
<div class="status-grid">
<article class="status-card">
<span>Host target</span>
<strong>bifrost.gamecult.org</strong>
</article>
<article class="status-card">
<span>Stack</span>
<strong>ASP.NET Core 8 + Razor Pages + HTMX</strong>
</article>
<article class="status-card">
<span>System of record</span>
<strong>PostgreSQL</strong>
</article>
</div>
</div>
</section>

View file

@ -0,0 +1,18 @@
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Bifrost.Web.Pages;
public sealed class IndexModel(ICurrentBifrostActorAccessor actorAccessor) : PageModel
{
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
[BindProperty(SupportsGet = true)]
public string? Auth { get; set; }
public async Task OnGetAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
}
}

View file

@ -0,0 +1,45 @@
@page
@model Bifrost.Web.Pages.Membership.StatusModel
@{
ViewData["Title"] = "Membership status";
}
<section class="panel">
<span class="eyebrow">Membership gate</span>
<h1>Current access state</h1>
<p class="panel-intro">
Authentication and participation are separate on purpose. Bifrost only opens the member console
after a GitHub account has cleared the invite and approval gates.
</p>
<div class="status-banner">
<div class="inline-actions">
<span class="pill @Model.StatusTone">@Model.StatusLabel</span>
@if (Model.Actor.IsActiveMember)
{
<span class="pill success">Voting weight @Model.Actor.EffectiveVotingWeight</span>
}
</div>
<strong>@Model.Actor.DisplayName</strong>
<p class="muted">@Model.StatusMessage</p>
</div>
@if (Model.Invitation is not null)
{
<div class="callout success">
Invitation tracked for <code>@Model.Invitation.GitHubLogin</code>.
Issued @Model.Invitation.IssuedAtUtc.ToString("dd MMM yyyy") UTC.
</div>
}
<div class="panel-actions">
@if (Model.Actor.IsActiveMember)
{
<a class="button" asp-page="/App/Index">Open member console</a>
}
else
{
<a class="button secondary" asp-page="/Index">Back to overview</a>
}
</div>
</section>

View file

@ -0,0 +1,61 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Pages.Membership;
[Authorize]
public sealed class StatusModel(
ICurrentBifrostActorAccessor actorAccessor,
BifrostDbContext dbContext) : PageModel
{
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public MembershipInvitation? Invitation { get; private set; }
public string StatusLabel => Actor.Membership?.Status switch
{
MembershipStatus.Active => "Active member",
MembershipStatus.PendingApproval => "Pending approval",
MembershipStatus.Suspended => "Suspended",
MembershipStatus.Authenticated => "Authenticated only",
_ => "Unknown"
};
public string StatusTone => Actor.Membership?.Status switch
{
MembershipStatus.Active => "success",
MembershipStatus.PendingApproval => "warning",
MembershipStatus.Suspended => "danger",
_ => string.Empty
};
public string StatusMessage => Actor.Membership?.Status switch
{
MembershipStatus.Active => "You can participate in projects, claim work, vote on motions, and view the ledger console.",
MembershipStatus.PendingApproval => "Your GitHub sign-in matched an invite, but an admin still needs to approve active participation.",
MembershipStatus.Suspended => "Your account is currently suspended from active participation pending an admin review.",
MembershipStatus.Authenticated => "You are signed in, but there is no active invite or approval on your account yet.",
_ => "No membership record was found."
};
public async Task OnGetAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
if (Actor.UserAccount is null)
{
return;
}
Invitation = await dbContext.MembershipInvitations
.AsNoTracking()
.SingleOrDefaultAsync(
x => x.NormalizedGitHubLogin == Actor.UserAccount.NormalizedGitHubLogin &&
x.RevokedAtUtc == null,
cancellationToken);
}
}

View file

@ -0,0 +1,8 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View file

@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Bifrost.Web.Pages;
public class PrivacyModel : PageModel
{
public void OnGet()
{
}
}

View file

@ -0,0 +1,69 @@
@using Bifrost.Web.Features.Membership
@using System.Security.Claims
@{
var gitHubLogin = Context.User.FindFirst(BifrostClaimTypes.GitHubLogin)?.Value
?? Context.User.FindFirst(ClaimTypes.Name)?.Value
?? "guest";
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Bifrost</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Literata:opsz,wght@7..72,500;7..72,700&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/Bifrost.Web.styles.css" asp-append-version="true" />
</head>
<body hx-boost="true">
<div class="shell">
<header class="topbar">
<a class="brand" asp-page="/Index">
<span class="brand-mark">B</span>
<span>
<strong>Bifrost</strong>
<small>GameCult member alpha</small>
</span>
</a>
<nav class="topnav">
<a asp-page="/Index">Home</a>
@if (User.Identity?.IsAuthenticated == true)
{
<a asp-page="/Membership/Status">Membership</a>
<a asp-page="/App/Index">Console</a>
<a asp-page="/App/Projects/Index">Projects</a>
<a asp-page="/App/WorkItems/Index">Work items</a>
<a asp-page="/App/Motions/Index">Motions</a>
<a asp-page="/App/Ledger/Index">Ledger</a>
<a asp-page="/App/Members/Index">Members</a>
}
</nav>
<div class="session-meta">
@if (User.Identity?.IsAuthenticated == true)
{
<span class="session-pill">@gitHubLogin</span>
<form method="post" action="/auth/sign-out">
<button class="button ghost" type="submit">Sign out</button>
</form>
}
else
{
<a class="button ghost" href="/auth/sign-in">GitHub sign-in</a>
}
</div>
</header>
<main class="page-frame">
@RenderBody()
</main>
</div>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View file

@ -0,0 +1,48 @@
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}

View file

@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>

View file

@ -0,0 +1,5 @@
@using Bifrost.Web
@using Bifrost.Web.Domain
@using Bifrost.Web.Features.Shared
@namespace Bifrost.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View file

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

152
src/Bifrost.Web/Program.cs Normal file
View file

@ -0,0 +1,152 @@
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<GitHubOAuthOptions>()
.BindConfiguration(GitHubOAuthOptions.SectionName);
builder.Services
.AddOptions<BootstrapOptions>()
.BindConfiguration(BootstrapOptions.SectionName);
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizePage("/Membership/Status");
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
});
builder.Services.AddDbContext<BifrostDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Bifrost")
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString);
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>();
builder.Services.AddScoped<AuditTrailService>();
builder.Services.AddScoped<DashboardSnapshotService>();
builder.Services.AddScoped<MembershipSynchronizationService>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/auth/sign-in";
options.AccessDeniedPath = "/Membership/Status";
options.SlidingExpiration = true;
})
.AddOAuth(GitHubAuthenticationDefaults.AuthenticationScheme, options =>
{
var gitHubOptions = builder.Configuration
.GetSection(GitHubOAuthOptions.SectionName)
.Get<GitHubOAuthOptions>() ?? new GitHubOAuthOptions();
options.ClientId = gitHubOptions.ClientId;
options.ClientSecret = gitHubOptions.ClientSecret;
options.CallbackPath = gitHubOptions.CallbackPath;
options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
options.TokenEndpoint = "https://github.com/login/oauth/access_token";
options.UserInformationEndpoint = "https://api.github.com/user";
options.SaveTokens = true;
options.Scope.Add("read:user");
options.Scope.Add("user:email");
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
options.ClaimActions.MapJsonKey(BifrostClaimTypes.GitHubLogin, "login");
options.ClaimActions.MapJsonKey(BifrostClaimTypes.DisplayName, "name");
options.ClaimActions.MapJsonKey(BifrostClaimTypes.AvatarUrl, "avatar_url");
options.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken);
request.Headers.UserAgent.ParseAdd("Bifrost");
using var response = await context.Backchannel.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
context.HttpContext.RequestAborted);
response.EnsureSuccessStatusCode();
await using var payloadStream = await response.Content.ReadAsStreamAsync(context.HttpContext.RequestAborted);
using var payload = await JsonDocument.ParseAsync(payloadStream, cancellationToken: context.HttpContext.RequestAborted);
context.RunClaimActions(payload.RootElement);
}
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(ActiveMemberRequirement.PolicyName, policy =>
{
policy.RequireAuthenticatedUser();
policy.AddRequirements(new ActiveMemberRequirement());
});
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseMiddleware<GitHubMembershipSynchronizationMiddleware>();
app.UseAuthorization();
app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuthOptions> gitHubOptions) =>
{
if (!gitHubOptions.Value.IsConfigured)
{
httpContext.Response.Redirect("/?auth=github-not-configured");
return;
}
var redirectUri = httpContext.Request.Query["returnUrl"].ToString();
if (string.IsNullOrWhiteSpace(redirectUri))
{
redirectUri = "/App";
}
await httpContext.ChallengeAsync(
GitHubAuthenticationDefaults.AuthenticationScheme,
new() { RedirectUri = redirectUri });
}).AllowAnonymous();
app.MapPost("/auth/sign-out", async (HttpContext httpContext) =>
{
await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
httpContext.Response.Redirect("/");
}).AllowAnonymous();
app.MapRazorPages();
app.Run();
public partial class Program;

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5221",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7171;http://localhost:5221",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,12 @@
{
"DetailedErrors": true,
"ConnectionStrings": {
"Bifrost": "Host=127.0.0.1;Database=bifrost_dev;Username=postgres;Password=postgres"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,20 @@
{
"ConnectionStrings": {
"Bifrost": "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"
},
"GitHubOAuth": {
"ClientId": "",
"ClientSecret": "",
"CallbackPath": "/signin-github"
},
"Bootstrap": {
"AdminGitHubLogins": []
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,489 @@
:root {
--bg: #f2ece1;
--bg-deep: #e0d5bf;
--panel: rgba(255, 251, 243, 0.85);
--panel-strong: #fffaf1;
--ink: #1a1a18;
--muted: #5c5a53;
--line: rgba(38, 34, 27, 0.14);
--accent: #23413f;
--accent-bright: #2f6f67;
--accent-warm: #9f5e39;
--danger: #9c463f;
--shadow: 0 20px 45px rgba(32, 28, 22, 0.11);
--radius-xl: 28px;
--radius-lg: 20px;
--radius-md: 14px;
}
* {
box-sizing: border-box;
}
html {
font-size: 16px;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(47, 111, 103, 0.18), transparent 28rem),
radial-gradient(circle at top right, rgba(159, 94, 57, 0.14), transparent 22rem),
linear-gradient(180deg, #f7f1e6 0%, var(--bg) 45%, #ede4d5 100%);
color: var(--ink);
font-family: "Space Grotesk", "Segoe UI", sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
code {
font-family: "Cascadia Code", Consolas, monospace;
font-size: 0.92em;
}
.shell {
width: min(1200px, calc(100% - 2rem));
margin: 0 auto;
padding: 1rem 0 2.5rem;
}
.topbar {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: center;
padding: 1rem 1.1rem;
margin-top: 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: rgba(255, 249, 241, 0.72);
backdrop-filter: blur(20px);
box-shadow: var(--shadow);
}
.brand {
display: inline-flex;
gap: 0.85rem;
align-items: center;
}
.brand strong {
display: block;
font-size: 1.1rem;
}
.brand small {
color: var(--muted);
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 2.65rem;
height: 2.65rem;
border-radius: 0.9rem;
background: linear-gradient(160deg, var(--accent), #102523);
color: #fff5e9;
font-family: "Literata", Georgia, serif;
font-size: 1.4rem;
font-weight: 700;
}
.topnav {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.topnav a,
.session-pill {
padding: 0.55rem 0.8rem;
border-radius: 999px;
color: var(--muted);
}
.topnav a:hover {
background: rgba(35, 65, 63, 0.08);
color: var(--accent);
}
.session-meta {
display: flex;
gap: 0.75rem;
align-items: center;
}
.page-frame {
padding: 1.8rem 0 0;
}
.hero,
.panel-grid {
display: grid;
gap: 1.5rem;
}
.hero {
grid-template-columns: 1.4fr 1fr;
align-items: start;
}
.hero-copy,
.hero-panel,
.panel,
.table-panel {
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: var(--panel);
box-shadow: var(--shadow);
}
.hero-copy,
.hero-panel,
.panel {
padding: 1.75rem;
}
.hero h1,
.panel h1,
.panel h2,
.hero-panel h2 {
margin-top: 0;
font-family: "Literata", Georgia, serif;
line-height: 1.05;
}
.hero h1 {
font-size: clamp(2.4rem, 4vw, 4.4rem);
margin-bottom: 1rem;
}
.lede,
.panel-intro {
color: var(--muted);
font-size: 1.05rem;
line-height: 1.7;
}
.eyebrow {
display: inline-block;
margin-bottom: 1rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
background: rgba(35, 65, 63, 0.08);
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
font-weight: 700;
}
.hero-actions,
.panel-actions,
.inline-actions {
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
border-radius: 999px;
padding: 0.85rem 1.15rem;
background: linear-gradient(135deg, var(--accent), var(--accent-bright));
color: #fffaf4;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease;
box-shadow: 0 12px 28px rgba(35, 65, 63, 0.2);
}
.button:hover {
transform: translateY(-1px);
}
.button.secondary,
.button.ghost {
background: rgba(255, 248, 236, 0.85);
color: var(--accent);
border: 1px solid rgba(35, 65, 63, 0.14);
box-shadow: none;
}
.button.warm {
background: linear-gradient(135deg, var(--accent-warm), #81462f);
}
.button.danger {
background: linear-gradient(135deg, var(--danger), #7f332d);
}
.stack-list {
margin: 1.1rem 0 0;
padding-left: 1.2rem;
line-height: 1.8;
}
.status-grid,
.metric-grid,
.form-grid {
display: grid;
gap: 1rem;
}
.status-grid,
.metric-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.status-card,
.metric-card {
padding: 1rem;
border-radius: var(--radius-lg);
background: var(--panel-strong);
border: 1px solid var(--line);
}
.status-card span,
.metric-card span,
.table-meta,
.hint,
.muted {
color: var(--muted);
}
.status-card strong,
.metric-card strong {
display: block;
margin-top: 0.35rem;
font-size: 1.05rem;
}
.metric-card strong {
font-size: 1.85rem;
line-height: 1;
}
.panel-grid {
grid-template-columns: 1fr 1fr;
}
.panel-header {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.table-panel {
overflow: hidden;
}
.table-shell {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 1rem 1.15rem;
text-align: left;
vertical-align: top;
border-bottom: 1px solid var(--line);
}
th {
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
tbody tr:hover {
background: rgba(255, 250, 242, 0.55);
}
.pill {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.6rem;
border-radius: 999px;
background: rgba(35, 65, 63, 0.08);
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
}
.pill.warning {
background: rgba(159, 94, 57, 0.12);
color: var(--accent-warm);
}
.pill.danger {
background: rgba(156, 70, 63, 0.12);
color: var(--danger);
}
.pill.success {
background: rgba(47, 111, 103, 0.12);
color: var(--accent-bright);
}
.callout {
margin: 1rem 0;
padding: 1rem 1.1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: rgba(255, 248, 237, 0.95);
}
.callout.warning {
border-color: rgba(159, 94, 57, 0.25);
background: rgba(255, 238, 224, 0.85);
}
.callout.success {
border-color: rgba(47, 111, 103, 0.25);
background: rgba(236, 247, 244, 0.9);
}
.form-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.field {
display: grid;
gap: 0.45rem;
}
.field label {
font-size: 0.92rem;
font-weight: 700;
}
.field input,
.field select,
.field textarea {
width: 100%;
border: 1px solid rgba(38, 34, 27, 0.14);
border-radius: 16px;
padding: 0.85rem 0.95rem;
background: #fffdf8;
color: var(--ink);
font: inherit;
}
.field textarea {
min-height: 7rem;
resize: vertical;
}
.validation-summary-errors,
.field-validation-error {
color: var(--danger);
}
.field-validation-error {
font-size: 0.9rem;
}
.list-grid {
display: grid;
gap: 1rem;
}
.motion-grid,
.preview-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.motion-card,
.preview-card {
padding: 1.2rem;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: var(--panel-strong);
}
.motion-card h3,
.preview-card h3 {
margin-top: 0;
font-family: "Literata", Georgia, serif;
}
.vote-strip {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.vote-strip form {
margin: 0;
}
.status-banner {
display: grid;
gap: 0.8rem;
align-items: start;
padding: 1.25rem;
border-radius: var(--radius-lg);
background: var(--panel-strong);
border: 1px solid var(--line);
}
.empty-state {
padding: 1.5rem;
border-radius: var(--radius-lg);
border: 1px dashed rgba(38, 34, 27, 0.18);
text-align: center;
color: var(--muted);
}
@media (max-width: 960px) {
.topbar,
.hero,
.panel-grid {
grid-template-columns: 1fr;
}
.topnav,
.session-meta {
justify-content: flex-start;
}
}
@media (max-width: 640px) {
.shell {
width: min(100% - 1rem, 1200px);
}
.topbar,
.hero-copy,
.hero-panel,
.panel {
padding: 1.2rem;
}
.panel-header {
flex-direction: column;
align-items: flex-start;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,4 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.

View file

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2011-2021 Twitter, Inc.
Copyright (c) 2011-2021 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,435 @@
/**
* @license
* Unobtrusive validation support library for jQuery and jQuery Validate
* Copyright (c) .NET Foundation. All rights reserved.
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
* @version v4.0.0
*/
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
/*global document: false, jQuery: false */
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS-like environments that support module.exports
module.exports = factory(require('jquery-validation'));
} else {
// Browser global
jQuery.validator.unobtrusive = factory(jQuery);
}
}(function ($) {
var $jQval = $.validator,
adapters,
data_validation = "unobtrusiveValidation";
function setValidationValues(options, ruleName, value) {
options.rules[ruleName] = value;
if (options.message) {
options.messages[ruleName] = options.message;
}
}
function splitAndTrim(value) {
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
}
function escapeAttributeValue(value) {
// As mentioned on http://api.jquery.com/category/selectors/
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
}
function getModelPrefix(fieldName) {
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}
function appendModelPrefix(value, prefix) {
if (value.indexOf("*.") === 0) {
value = value.replace("*.", prefix);
}
return value;
}
function onError(error, inputElement) { // 'this' is the form element
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
container.removeClass("field-validation-valid").addClass("field-validation-error");
error.data("unobtrusiveContainer", container);
if (replace) {
container.empty();
error.removeClass("input-validation-error").appendTo(container);
}
else {
error.hide();
}
}
function onErrors(event, validator) { // 'this' is the form element
var container = $(this).find("[data-valmsg-summary=true]"),
list = container.find("ul");
if (list && list.length && validator.errorList.length) {
list.empty();
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
$.each(validator.errorList, function () {
$("<li />").html(this.message).appendTo(list);
});
}
}
function onSuccess(error) { // 'this' is the form element
var container = error.data("unobtrusiveContainer");
if (container) {
var replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
container.addClass("field-validation-valid").removeClass("field-validation-error");
error.removeData("unobtrusiveContainer");
if (replace) {
container.empty();
}
}
}
function onReset(event) { // 'this' is the form element
var $form = $(this),
key = '__jquery_unobtrusive_validation_form_reset';
if ($form.data(key)) {
return;
}
// Set a flag that indicates we're currently resetting the form.
$form.data(key, true);
try {
$form.data("validator").resetForm();
} finally {
$form.removeData(key);
}
$form.find(".validation-summary-errors")
.addClass("validation-summary-valid")
.removeClass("validation-summary-errors");
$form.find(".field-validation-error")
.addClass("field-validation-valid")
.removeClass("field-validation-error")
.removeData("unobtrusiveContainer")
.find(">*") // If we were using valmsg-replace, get the underlying error
.removeData("unobtrusiveContainer");
}
function validationInfo(form) {
var $form = $(form),
result = $form.data(data_validation),
onResetProxy = $.proxy(onReset, form),
defaultOptions = $jQval.unobtrusive.options || {},
execInContext = function (name, args) {
var func = defaultOptions[name];
func && $.isFunction(func) && func.apply(form, args);
};
if (!result) {
result = {
options: { // options structure passed to jQuery Validate's validate() method
errorClass: defaultOptions.errorClass || "input-validation-error",
errorElement: defaultOptions.errorElement || "span",
errorPlacement: function () {
onError.apply(form, arguments);
execInContext("errorPlacement", arguments);
},
invalidHandler: function () {
onErrors.apply(form, arguments);
execInContext("invalidHandler", arguments);
},
messages: {},
rules: {},
success: function () {
onSuccess.apply(form, arguments);
execInContext("success", arguments);
}
},
attachValidation: function () {
$form
.off("reset." + data_validation, onResetProxy)
.on("reset." + data_validation, onResetProxy)
.validate(this.options);
},
validate: function () { // a validation function that is called by unobtrusive Ajax
$form.validate();
return $form.valid();
}
};
$form.data(data_validation, result);
}
return result;
}
$jQval.unobtrusive = {
adapters: [],
parseElement: function (element, skipAttach) {
/// <summary>
/// Parses a single HTML element for unobtrusive validation attributes.
/// </summary>
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
/// validation to the form. If parsing just this single element, you should specify true.
/// If parsing several elements, you should specify false, and manually attach the validation
/// to the form when you are finished. The default is false.</param>
var $element = $(element),
form = $element.parents("form")[0],
valInfo, rules, messages;
if (!form) { // Cannot do client-side validation without a form
return;
}
valInfo = validationInfo(form);
valInfo.options.rules[element.name] = rules = {};
valInfo.options.messages[element.name] = messages = {};
$.each(this.adapters, function () {
var prefix = "data-val-" + this.name,
message = $element.attr(prefix),
paramValues = {};
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
prefix += "-";
$.each(this.params, function () {
paramValues[this] = $element.attr(prefix + this);
});
this.adapt({
element: element,
form: form,
message: message,
params: paramValues,
rules: rules,
messages: messages
});
}
});
$.extend(rules, { "__dummy__": true });
if (!skipAttach) {
valInfo.attachValidation();
}
},
parse: function (selector) {
/// <summary>
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
/// attribute values.
/// </summary>
/// <param name="selector" type="String">Any valid jQuery selector.</param>
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
// element with data-val=true
var $selector = $(selector),
$forms = $selector.parents()
.addBack()
.filter("form")
.add($selector.find("form"))
.has("[data-val=true]");
$selector.find("[data-val=true]").each(function () {
$jQval.unobtrusive.parseElement(this, true);
});
$forms.each(function () {
var info = validationInfo(this);
if (info) {
info.attachValidation();
}
});
}
};
adapters = $jQval.unobtrusive.adapters;
adapters.add = function (adapterName, params, fn) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
/// mmmm is the parameter name).</param>
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
/// attributes into jQuery Validate rules and/or messages.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
if (!fn) { // Called with no params, just a function
fn = params;
params = [];
}
this.push({ name: adapterName, params: params, adapt: fn });
return this;
};
adapters.addBool = function (adapterName, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has no parameter values.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, function (options) {
setValidationValues(options, ruleName || adapterName, true);
});
};
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a minimum value.</param>
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a maximum value.</param>
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
/// have both a minimum and maximum value.</param>
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the minimum value. The default is "min".</param>
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the maximum value. The default is "max".</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
var min = options.params.min,
max = options.params.max;
if (min && max) {
setValidationValues(options, minMaxRuleName, [min, max]);
}
else if (min) {
setValidationValues(options, minRuleName, min);
}
else if (max) {
setValidationValues(options, maxRuleName, max);
}
});
};
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has a single value.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
/// The default is "val".</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [attribute || "val"], function (options) {
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
});
};
$jQval.addMethod("__dummy__", function (value, element, params) {
return true;
});
$jQval.addMethod("regex", function (value, element, params) {
var match;
if (this.optional(element)) {
return true;
}
match = new RegExp(params).exec(value);
return (match && (match.index === 0) && (match[0].length === value.length));
});
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
var match;
if (nonalphamin) {
match = value.match(/\W/g);
match = match && match.length >= nonalphamin;
}
return match;
});
if ($jQval.methods.extension) {
adapters.addSingleVal("accept", "mimtype");
adapters.addSingleVal("extension", "extension");
} else {
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
// validating the extension, and ignore mime-type validations as they are not supported.
adapters.addSingleVal("extension", "extension", "accept");
}
adapters.addSingleVal("regex", "pattern");
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
adapters.add("equalto", ["other"], function (options) {
var prefix = getModelPrefix(options.element.name),
other = options.params.other,
fullOtherName = appendModelPrefix(other, prefix),
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
setValidationValues(options, "equalTo", element);
});
adapters.add("required", function (options) {
// jQuery Validate equates "required" with "mandatory" for checkbox elements
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
setValidationValues(options, "required", true);
}
});
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
var value = {
url: options.params.url,
type: options.params.type || "GET",
data: {}
},
prefix = getModelPrefix(options.element.name);
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
var paramName = appendModelPrefix(fieldName, prefix);
value.data[paramName] = function () {
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
// For checkboxes and radio buttons, only pick up values from checked fields.
if (field.is(":checkbox")) {
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
}
else if (field.is(":radio")) {
return field.filter(":checked").val() || '';
}
return field.val();
};
});
setValidationValues(options, "remote", value);
});
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
if (options.params.min) {
setValidationValues(options, "minlength", options.params.min);
}
if (options.params.nonalphamin) {
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
}
if (options.params.regex) {
setValidationValues(options, "regex", options.params.regex);
}
});
adapters.add("fileextensions", ["extensions"], function (options) {
setValidationValues(options, "extension", options.params.extensions);
});
$(function () {
$jQval.unobtrusive.parse(document);
});
return $jQval.unobtrusive;
}));

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more