diff --git a/README.md b/README.md index 3469e049..abaaeb25 100644 --- a/README.md +++ b/README.md @@ -324,15 +324,30 @@ Start with the simple API. Drop down to UltimateAuth's extensibility points when --- -## Documentation +## Documentation & Examples -Two documentation experiences are provided: +UltimateAuth provides three ways to learn, explore, and experience the framework. -### **1) Classic Documentation** -Guides, API reference, tutorials - https://ultimateauth.com +### **1) Documentation** +Comprehensive guides, API references, tutorials, and architectural explanations to help you understand and integrate UltimateAuth. -### **2) Interactive Identity Sandbox** (Available Soon) -Create accounts, simulate devices, test auth flows, and observe UltimateAuth in action. +📖 **[Explore Documentation](https://ultimateauth.com/docs)** + +### **2) Runnable Samples** +Go beyond code snippets. UltimateAuth includes multiple complete, ready-to-run sample applications covering different hosting models, authentication flows, and integration scenarios. + +Clone the repository, open the solution in your IDE, and run the samples directly. No need to build a demo application from scratch. + +💻 **[Explore Samples](https://github.com/CodeBeamOrg/UltimateAuth/tree/dev/samples)** + +### **3) Identity Lab — Interactive Sandbox** +Experience UltimateAuth directly in your browser through a free, interactive sandbox. + +Create test accounts, manage identities and sessions, explore roles and permissions, and experiment with real authentication and authorization flows. + +**Powered by UltimateAuth, not simulated.** The demonstrated identity capabilities use the framework itself, so you can see what UltimateAuth can do before integrating it into your own application. + +🧪 **Identity Lab — Interactive Sandbox** *(Link coming soon)* --- diff --git a/ROADMAP.md b/ROADMAP.md index 323f459f..e9fa88a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,10 +1,109 @@ # UltimateAuth Roadmap -> The project roadmap is actively maintained as a GitHub issue: -👉 https://github.com/CodeBeamOrg/UltimateAuth/issues/8 +> **The detailed and actively maintained roadmap is available on GitHub:** +> +> 👉 **[UltimateAuth Roadmap — Issue #8](https://github.com/CodeBeamOrg/UltimateAuth/issues/8)** +> +> This document provides a high-level overview of our development direction, major milestones, and long-term goals. For detailed tasks, progress tracking, and planned deliverables, see the GitHub issue above. --- -UltimateAuth is developed openly and shaped by community feedback. +## Our Direction -Thank you for helping us build the next-generation authentication framework for .NET. +UltimateAuth aims to become a modern, unified, and extensible authentication and authorization framework for .NET. + +Our development approach prioritizes **security, architectural consistency, developer experience, and real-world usability**. + +We are building UltimateAuth incrementally, starting with a reliable authentication foundation and expanding toward production-ready infrastructure, advanced security capabilities, and a broader .NET ecosystem. + +## Development Milestones + +### Phase 0 — Foundation + +**Status: Completed** + +Establish the core authentication architecture, including sessions, authentication chains, tokens, flows, persistence abstractions, and server infrastructure. + +The foundation provides the architectural basis for future capabilities without requiring fundamental redesigns as the framework evolves. + +### Phase 1 — First Public Release (v0.1.0) + +**Focus: A working, tested and documented end-to-end auth framework** + +**Status: Completed** + +Deliver the first usable version of UltimateAuth, including core authentication workflows, session management, user and authorization fundamentals, client integrations, and persistence support. + +Provide comprehensive documentation, runnable samples, and practical examples to help developers understand and evaluate the framework. + +### Phase 2 — Broader Adoption (v0.2.0) + +**Focus: Complete product fundamentals** + +Improve the stability and usability of public APIs while expanding authentication modes, credential policies, security controls, and integration testing. + +Strengthen developer experience across supported .NET application models and make UltimateAuth easier to adopt in real applications. + +### Phase 3 — Production Expansion (v0.3.0–v0.8.0) + +**Focus: Security, scalability, and ecosystem growth** + +Progressively introduce advanced authentication security, multi-factor authentication, improved infrastructure integrations, distributed persistence, and enhanced multi-tenancy. + +Expand observability, audit capabilities, performance optimization, platform support, and migration tooling. + +Each release will focus on a defined area while preserving architectural consistency across the framework. + +### Phase 4 — Stable Contracts (v1.0.0) + +**Focus: Long-term API stability and production confidence** + +Establish a stable public API contract backed by comprehensive security reviews, integration and regression testing, hardened authentication flows, and mature documentation. + +The goal of v1.0.0 is to provide a dependable foundation for applications that require predictable behavior and long-term maintainability. + +### Phase 5 — Long-Term Evolution + +**Focus: Advanced capabilities and community-driven growth** + +Continue evolving UltimateAuth through advanced authorization policies, external identity integrations, extensible authentication providers, enterprise observability, and long-term support planning. + +Our long-term vision is to make UltimateAuth a first-class authentication framework within the .NET ecosystem. + +--- + +## Documentation, Samples & Identity Lab + +Developer experience remains a priority throughout the roadmap. + +UltimateAuth provides multiple ways to learn and evaluate the framework: + +- **Documentation:** Architecture, concepts, integration guides, and API references. +- **Runnable Samples:** Complete applications that developers can clone, run, inspect, and modify in their own development environments. +- **Identity Lab:** An interactive sandbox designed to demonstrate real UltimateAuth authentication and authorization capabilities directly in the browser. + +These resources will continue to evolve alongside the framework. + +## An Open and Evolving Roadmap + +**This roadmap is a direction, not a fixed commitment.** + +Priorities, implementation details, release scope, and timelines may change based on security findings, technical requirements, real-world adoption, and community feedback. + +Features may be introduced earlier, postponed, redesigned, or reconsidered when doing so improves the overall quality and sustainability of UltimateAuth. + +We value architectural consistency and reliability over rushing features to meet arbitrary deadlines. + +## Community & Contributions + +UltimateAuth is developed openly, and community participation plays an important role in shaping its future. + +We welcome bug reports, feature requests, architectural discussions, documentation improvements, and code contributions. + +If you have an idea or a use case that could improve UltimateAuth, join the conversation through GitHub Issues and Discussions. + +**[View the Detailed Roadmap](https://github.com/CodeBeamOrg/UltimateAuth/issues/8)** · **[Explore the Repository](https://github.com/CodeBeamOrg/UltimateAuth)** + +--- + +*Building a modern authentication framework for .NET, together.* \ No newline at end of file diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 38a6f7cc..050c32e0 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,4 +1,7 @@ + + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj new file mode 100644 index 00000000..5987c73f --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/CodeBeam.UltimateAuth.IdentityLab.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + true + false + 4f9706a0-d4d2-476c-bd8f-9bd1cd4f6f0e + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/App.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/App.razor new file mode 100644 index 00000000..1a81f123 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/App.razor @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/DashboardSidebar.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/DashboardSidebar.razor new file mode 100644 index 00000000..e02d249d --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/DashboardSidebar.razor @@ -0,0 +1,33 @@ + + + SANDBOX + + + + Overview + + + + Members + + + + Sessions & Devices + + + + Roles & Permissions + + + + + + Back to Home + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor new file mode 100644 index 00000000..d688b780 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor @@ -0,0 +1,65 @@ +
+ + + + + + + +
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor.css new file mode 100644 index 00000000..437dfd08 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicFooter.razor.css @@ -0,0 +1,112 @@ +.lab-footer { + padding: 36px 0 24px; + background: var(--mud-palette-surface); + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-footer-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding-bottom: 30px; +} + +.lab-footer-brand { + display: flex; + align-items: center; + gap: 14px; +} + +.lab-footer-symbol { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: 13px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); + flex-shrink: 0; +} + +.lab-footer-brand-text { + display: flex; + flex-direction: column; + gap: 4px; +} + + .lab-footer-brand-text strong { + font-size: 1.05rem; + font-weight: 800; + letter-spacing: -0.4px; + color: var(--mud-palette-text-primary); + } + + .lab-footer-brand-text span { + font-size: 0.8rem; + color: var(--mud-palette-text-secondary); + } + +.lab-footer-links { + display: flex; + align-items: center; + gap: 6px; +} + +.lab-footer-bottom { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding-top: 20px; + border-top: 1px solid var(--mud-palette-lines-default); + font-size: 0.75rem; + color: var(--mud-palette-text-secondary); +} + + .lab-footer-bottom a { + color: var(--mud-palette-text-primary); + font-weight: 600; + text-decoration: none; + transition: color 0.2s ease; + } + + .lab-footer-bottom a:hover { + color: var(--mud-palette-primary); + } + +.lab-footer-powered { + display: inline-flex; + align-items: center; + gap: 5px; +} + +@media (max-width: 600px) { + .lab-footer { + padding: 28px 0 20px; + } + + .lab-footer-main { + gap: 16px; + padding-bottom: 22px; + } + + .lab-footer-brand-text span { + font-size: 0.72rem; + } + + .lab-footer-bottom { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } +} + +@media (max-width: 380px) { + .lab-footer-main { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor new file mode 100644 index 00000000..173438e0 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor @@ -0,0 +1,63 @@ +@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure +@inject DarkModeManager DarkModeManager + +
+ +
+ + + + + + + + Identity Lab + by UltimateAuth + + + +
+ +
+ + + +
+ + + + + + + + Launch Lab + Launch + + + +
+
+
+
+ +@code { + private async Task ToggleTheme() + { + await DarkModeManager.ToggleAsync(); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor.css new file mode 100644 index 00000000..6ba9b0cc --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Custom/PublicHeader.razor.css @@ -0,0 +1,85 @@ +.lab-header { + position: sticky; + top: 0; + z-index: 1100; + width: 100%; + background: var(--mud-palette-surface); + border-bottom: 1px solid var(--mud-palette-lines-default); + background: var(--mud-palette-surface); + background: color-mix( in srgb, var(--mud-palette-surface) 92%, transparent ); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.lab-header-inner { + min-height: 72px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.lab-brand { + display: inline-flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: var(--mud-palette-text-primary); +} + +.lab-brand-symbol { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); +} + +.lab-brand-text { + display: flex; + flex-direction: column; + gap: 2px; +} + + .lab-brand-text strong { + font-size: 1.2rem; + font-weight: 800; + letter-spacing: -0.6px; + line-height: 1.15; + } + + .lab-brand-text small { + font-size: 0.7rem; + font-weight: 500; + color: var(--mud-palette-text-secondary); + } + +.lab-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +@media (max-width: 600px) { + .lab-header-inner { + min-height: 64px; + gap: 12px; + } + + .lab-brand-text strong { + font-size: 1.05rem; + } + + .lab-header-actions { + gap: 2px; + } +} + +@media (max-width: 600px) { + .lab-header-github { + display: none; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor new file mode 100644 index 00000000..2f518741 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor @@ -0,0 +1,172 @@ +@inherits LayoutComponentBase +@implements IDisposable +@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure +@inject DarkModeManager DarkModeManager + + + + + + +
+ + + + + + + + + + Identity Lab + Sandbox Workspace + + + + + + + + + + + + + + + + + +
+ + + +
+ +
+ + + +
+ My Sandbox + Identity environment +
+ +
+ + +
WORKSPACE
+ + + Overview + + +
PERSONAL
+ + + My Identity + + + + My Sessions + + + + My Access + + + + Authorization Lab + + + @if (ShowManagement) + { +
SANDBOX MANAGEMENT
+ + + Users + + + + Authorization + + + + Activity + + + + Settings + + } +
+ + + +
+ + Powered by UltimateAuth +
+
+
+ + + @Body + + +
+ +@code { + private bool _drawerOpen = true; + + // UI preview only. Replace with sandbox-scoped authorization. + private bool ShowManagement => true; + + protected override void OnInitialized() + { + DarkModeManager.Changed += OnThemeChanged; + } + + private void OnThemeChanged() + { + _ = InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + DarkModeManager.Changed -= OnThemeChanged; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css new file mode 100644 index 00000000..b0fb97c9 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/DashboardLayout.razor.css @@ -0,0 +1,159 @@ +.lab-dashboard-layout { + background: var(--mud-palette-background); + min-height: 100vh; +} + +.lab-dashboard-appbar { + background: var(--mud-palette-surface) !important; + color: var(--mud-palette-text-primary) !important; + border-bottom: 1px solid var(--mud-palette-lines-default); +} + +.lab-dashboard-brand { + display: inline-flex; + align-items: center; + gap: 11px; + color: var(--mud-palette-text-primary); + text-decoration: none; +} + +.lab-dashboard-brand-icon { + width: 38px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-dashboard-brand > span:last-child { + display: flex; + flex-direction: column; + gap: 2px; +} + +.lab-dashboard-brand strong { + font-size: 1rem; + font-weight: 800; + letter-spacing: -.5px; + line-height: 1.1; +} + +.lab-dashboard-brand small { + font-size: .68rem; + color: var(--mud-palette-text-secondary); +} + +.lab-dashboard-drawer { + background: var(--mud-palette-surface) !important; + border-right: 1px solid var(--mud-palette-lines-default); +} + +.lab-sidebar-content { + display: flex; + flex-direction: column; + min-height: 100%; + padding: 20px 12px; +} + +.lab-sidebar-sandbox { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + margin-bottom: 22px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 13px; +} + +.lab-sidebar-sandbox-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-sidebar-sandbox > div { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.lab-sidebar-sandbox strong { + font-size: .8rem; + font-weight: 700; +} + +.lab-sidebar-sandbox small { + font-size: .68rem; + color: var(--mud-palette-text-secondary); +} + +.lab-nav-label { + margin: 19px 12px 9px; + color: var(--mud-palette-text-secondary); + font-size: .64rem; + font-weight: 800; + letter-spacing: 1.1px; +} + +.lab-sidebar-content ::deep .mud-nav-link { + margin: 2px 0; + border-radius: 10px; + font-size: .84rem; +} + + .lab-sidebar-content ::deep .mud-nav-link.active { + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + font-weight: 650; + } + +.lab-sidebar-bottom { + display: flex; + align-items: center; + gap: 8px; + padding: 18px 12px 4px; + color: var(--mud-palette-text-secondary); + font-size: .72rem; +} + +.lab-dashboard-main { + min-height: calc(100vh - var(--mud-appbar-height)); +} + +.lab-mobile-menu { + display: none; +} + +@media (max-width: 960px) { + .lab-mobile-menu { + display: inline-flex; + } +} + +@media (max-width: 600px) { + .lab-dashboard-brand small { + display: none; + } + + .lab-dashboard-brand-icon { + width: 34px; + height: 34px; + } +} + +.lab-appbar-host ::deep .mud-appbar { + background-color: var(--mud-palette-surface) !important; + color: var(--mud-palette-text-primary) !important; + border-bottom: 1px solid var(--mud-palette-lines-default); + box-shadow: none !important; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/PublicLayout.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/PublicLayout.razor new file mode 100644 index 00000000..b740df5e --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/PublicLayout.razor @@ -0,0 +1,15 @@ +@inherits LayoutComponentBase + + + +
+ + +
+ @Body +
+ + +
+
+
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/PublicLayout.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/PublicLayout.razor.css new file mode 100644 index 00000000..20295088 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/PublicLayout.razor.css @@ -0,0 +1,10 @@ +.lab-public-shell { + display: flex; + flex-direction: column; + min-height: 100dvh; +} + +.lab-public-content { + flex: 1; + min-width: 0; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor new file mode 100644 index 00000000..e740b0c8 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 00000000..3ad3773f --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor.js b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 00000000..a44de78d --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AdminPermissionPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AdminPermissionPage.razor new file mode 100644 index 00000000..90492aef --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AdminPermissionPage.razor @@ -0,0 +1,6 @@ +@page "/dashboard/auth/permission-admin" +@using CodeBeam.UltimateAuth.Core.Defaults +@attribute [UAuthAuthorize(Permissions = UAuthActions.Users.QueryAdmin)] + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AdminRolePage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AdminRolePage.razor new file mode 100644 index 00000000..da784fbe --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AdminRolePage.razor @@ -0,0 +1,5 @@ +@page "/dashboard/auth/admin" +@attribute [UAuthAuthorize(Roles = "Admin")] + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AspNetAuthorizePage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AspNetAuthorizePage.razor new file mode 100644 index 00000000..5035cdd4 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AspNetAuthorizePage.razor @@ -0,0 +1,5 @@ +@page "/dashboard/auth/aspnet" +@attribute [Authorize] + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AuthenticatedPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AuthenticatedPage.razor new file mode 100644 index 00000000..69ab6fa3 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/AuthenticatedPage.razor @@ -0,0 +1,5 @@ +@page "/dashboard/auth/authenticated" +@attribute [UAuthAuthorize] + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/CombinedAuthorizationPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/CombinedAuthorizationPage.razor new file mode 100644 index 00000000..3eef3872 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/CombinedAuthorizationPage.razor @@ -0,0 +1,8 @@ +@page "/dashboard/auth/combined" +@using CodeBeam.UltimateAuth.Core.Defaults +@attribute [UAuthAuthorize(Permissions = UAuthActions.Users.QueryAdmin)] +@attribute [Authorize(Roles = "Admin")] + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/MatchModesPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/MatchModesPage.razor new file mode 100644 index 00000000..60f0df0e --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/MatchModesPage.razor @@ -0,0 +1,4 @@ +@page "/dashboard/auth/match-modes" +@using CodeBeam.UltimateAuth.Sample.Shared.Components.Custom + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/PublicPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/PublicPage.razor new file mode 100644 index 00000000..4b420c68 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/PublicPage.razor @@ -0,0 +1,5 @@ +@page "/dashboard/auth/public" + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/StateViewPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/StateViewPage.razor new file mode 100644 index 00000000..1a144fa8 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/AuthorizationLab/StateViewPage.razor @@ -0,0 +1,4 @@ +@page "/dashboard/auth/state-view" +@using CodeBeam.UltimateAuth.Sample.Shared.Components.Custom + + diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor new file mode 100644 index 00000000..136e1bd9 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor @@ -0,0 +1,293 @@ +@page "/dashboard/activity" +@layout DashboardLayout + +@* TODO: Enable sandbox-scoped authorization. +@attribute [UAuthAuthorize] +*@ + +Activity · Identity Lab + +
+ + +
+
+ SANDBOX MANAGEMENT +

Activity

+

Review identity, session, and authorization activity within your sandbox.

+
+ + + Demo Preview + +
+ +
+
+ + Recorded Events + @_events.Count + Example activity records +
+ +
+ + Actors + @_events.Select(x => x.Actor).Distinct().Count() + Example users +
+ +
+ + Security Events + @_events.Count(x => x.Category == ActivityCategory.Security) + Illustrative security activity +
+
+ +
+ +
+
+

Activity Log

+

Explore events recorded within this sandbox.

+
+ + + Export + +
+ +
+ + + + All categories + Identity + Session + Authorization + Security + + + + All dates + Last 7 days + Last 30 days + +
+ + + + + Event + Actor + Category + Date + Details + + + + +
+ + + +
+ @context.Title + @context.Description +
+
+
+ + + @context.Actor + + + + + @context.Category + + + + + + @context.Timestamp.ToString("MMM dd, yyyy") + + + + +
+ +
+
+
+ + +
+ + No matching events +

Try adjusting your filters.

+
+
+ + + + + +
+
+ + @if (_selectedEvent is not null) + { +
+
+
+

Event Details

+

Example activity record

+
+ +
+ +
+
Event@_selectedEvent.Title
+
Actor@_selectedEvent.Actor
+
Category@_selectedEvent.Category
+
Timestamp (UTC)@_selectedEvent.Timestamp.ToString("yyyy-MM-dd HH:mm")
+
+ +

+ @_selectedEvent.Description +

+
+ } + + + Activity records shown here are illustrative. Production activity + will require explicit event recording, sandbox isolation, and + appropriate data retention rules. + + +
+
+ +@code { + private string _search = string.Empty; + private string _category = "all"; + private string _period = "all"; + + private ActivityPreview? _selectedEvent; + + private static readonly DateTime PreviewDate = + new(2026, 10, 21, 12, 0, 0, DateTimeKind.Utc); + + private readonly List _events = + [ + new("User Created", "A new member was added to the sandbox.", + "alex", ActivityCategory.Identity, PreviewDate.AddHours(-2)), + + new("Session Revoked", "A remote session was revoked.", + "taylor", ActivityCategory.Session, PreviewDate.AddHours(-5)), + + new("Role Updated", "A sandbox role configuration was changed.", + "alex", ActivityCategory.Authorization, PreviewDate.AddDays(-1)), + + new("Password Changed", "A member updated their credential.", + "jordan", ActivityCategory.Security, PreviewDate.AddDays(-2)), + + new("User Disabled", "A member account was disabled.", + "alex", ActivityCategory.Identity, PreviewDate.AddDays(-4)), + + new("Session Created", "A new authenticated session was established.", + "taylor", ActivityCategory.Session, PreviewDate.AddDays(-9)) + ]; + + private IEnumerable FilteredEvents => + _events + .Where(x => + (string.IsNullOrWhiteSpace(_search) || + x.Title.Contains(_search, StringComparison.OrdinalIgnoreCase) || + x.Description.Contains(_search, StringComparison.OrdinalIgnoreCase) || + x.Actor.Contains(_search, StringComparison.OrdinalIgnoreCase)) && + (_category == "all" || + x.Category.ToString().Equals(_category, StringComparison.OrdinalIgnoreCase)) && + (_period == "all" || + (int.TryParse(_period, out var days) && + x.Timestamp >= PreviewDate.AddDays(-days)))) + .OrderByDescending(x => x.Timestamp); + + private void SelectEvent(ActivityPreview item) => _selectedEvent = item; + + private static string GetIcon(ActivityCategory category) => category switch + { + ActivityCategory.Identity => Icons.Material.Filled.PersonOutline, + ActivityCategory.Session => Icons.Material.Filled.Devices, + ActivityCategory.Authorization => Icons.Material.Filled.Shield, + ActivityCategory.Security => Icons.Material.Filled.Security, + _ => Icons.Material.Outlined.Info + }; + + private static Color GetColor(ActivityCategory category) => category switch + { + ActivityCategory.Identity => Color.Primary, + ActivityCategory.Session => Color.Info, + ActivityCategory.Authorization => Color.Secondary, + ActivityCategory.Security => Color.Warning, + _ => Color.Default + }; + + private enum ActivityCategory + { + Identity, + Session, + Authorization, + Security + } + + private sealed record ActivityPreview( + string Title, + string Description, + string Actor, + ActivityCategory Category, + DateTime Timestamp); +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor.css new file mode 100644 index 00000000..33837d51 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/ActivityPage.razor.css @@ -0,0 +1,290 @@ +.lab-activity-page { + padding: 42px 0 80px; +} + +.lab-activity-heading, +.lab-activity-panel-heading, +.lab-activity-details-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.lab-activity-heading { + margin-bottom: 30px; +} + +.lab-activity-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1.2px; +} + +.lab-activity-heading h1 { + margin: 0; + font-size: clamp(1.7rem, 3vw, 2.15rem); + font-weight: 800; + letter-spacing: -1px; +} + +.lab-activity-heading p, +.lab-activity-panel-heading p, +.lab-activity-details-heading p { + margin: 8px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .83rem; + line-height: 1.6; +} + +.lab-activity-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + margin-bottom: 24px; +} + +.lab-activity-stat, +.lab-activity-panel, +.lab-activity-details { + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 16px; +} + +.lab-activity-stat { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 23px; +} + + .lab-activity-stat > .mud-icon-root { + color: var(--mud-palette-primary); + } + + .lab-activity-stat > span { + margin-top: 16px; + color: var(--mud-palette-text-secondary); + font-size: .77rem; + } + + .lab-activity-stat strong { + margin-top: 5px; + font-size: 1.5rem; + font-weight: 800; + } + + .lab-activity-stat small { + margin-top: 5px; + color: var(--mud-palette-text-secondary); + font-size: .72rem; + } + +.lab-activity-panel { + overflow: hidden; +} + +.lab-activity-panel-heading { + align-items: center; + padding: 25px; +} + + .lab-activity-panel-heading h2, + .lab-activity-details-heading h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 750; + } + +.lab-activity-toolbar { + display: flex; + gap: 14px; + padding: 0 25px 23px; +} + +.lab-activity-search { + flex: 1; + min-width: 0; +} + +.lab-activity-filter { + width: 170px; + flex-shrink: 0; +} + +.lab-activity-panel ::deep .lab-activity-table { + background: transparent; + box-shadow: none; +} + +.lab-activity-panel ::deep .mud-table-head { + background: var(--mud-palette-background-gray); +} + +.lab-activity-panel ::deep .mud-table-cell { + padding: 15px 20px; + border-color: var(--mud-palette-lines-default); +} + +.lab-activity-event { + display: flex; + align-items: center; + gap: 13px; + min-width: 210px; +} + +.lab-activity-event-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 38px; + height: 38px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-activity-event > div { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.lab-activity-event strong { + font-size: .82rem; + font-weight: 700; +} + +.lab-activity-event span, +.lab-activity-date, +.lab-activity-actor { + color: var(--mud-palette-text-secondary); + font-size: .74rem; + line-height: 1.5; +} + +.lab-activity-actions { + display: flex; + justify-content: flex-end; +} + +.lab-activity-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + padding: 45px 15px; +} + + .lab-activity-empty strong { + font-size: .88rem; + } + + .lab-activity-empty p { + margin: 0; + color: var(--mud-palette-text-secondary); + font-size: .76rem; + } + +.lab-activity-details { + margin-top: 22px; + padding: 25px; +} + +.lab-activity-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: 25px; +} + + .lab-activity-detail-grid > div { + display: flex; + flex-direction: column; + gap: 7px; + } + + .lab-activity-detail-grid span { + color: var(--mud-palette-text-secondary); + font-size: .73rem; + } + + .lab-activity-detail-grid strong { + font-size: .83rem; + font-weight: 700; + overflow-wrap: anywhere; + } + +.lab-activity-detail-description { + margin: 22px 0 0; + padding-top: 18px; + border-top: 1px solid var(--mud-palette-lines-default); + color: var(--mud-palette-text-secondary); + font-size: .8rem; + line-height: 1.7; +} + +@media (max-width: 900px) { + .lab-activity-toolbar { + flex-wrap: wrap; + } + + .lab-activity-search { + flex-basis: 100%; + } + + .lab-activity-filter { + flex: 1; + } +} + +@media (max-width: 760px) { + .lab-activity-page { + padding: 28px 0 52px; + } + + .lab-activity-heading, + .lab-activity-panel-heading { + flex-direction: column; + } + + .lab-activity-stats { + grid-template-columns: 1fr; + gap: 12px; + } + + .lab-activity-panel-heading { + padding: 20px; + } + + .lab-activity-toolbar { + flex-direction: column; + padding: 0 20px 20px; + } + + .lab-activity-search, + .lab-activity-filter { + width: 100%; + flex: auto; + } + + .lab-activity-panel ::deep .mud-table-cell { + padding: 12px 16px; + } +} + +@media (max-width: 480px) { + .lab-activity-details { + padding: 20px; + } + + .lab-activity-detail-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor new file mode 100644 index 00000000..396b0b87 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor @@ -0,0 +1,193 @@ +@page "/dashboard/authorization-lab" +@layout DashboardLayout + + + +
+
+ +
+ +
+ + Authorization Lab + + + + Explore how UltimateAuth protects routes, evaluates + authorization requirements, and controls component visibility. + +
+
+ + + These examples demonstrate authorization behavior in real + applications. Each example will open in a new tab once available. + + + + + + +
+
+ +
+ Component Authorization + + Explore conditional UI rendering based + on the current user's authorization state. + +
+
+ + +
+ +
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor.css new file mode 100644 index 00000000..e786838d --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationLab.razor.css @@ -0,0 +1,132 @@ +.auth-lab-header { + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 24px; +} + +.auth-lab-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 64px; + height: 64px; + border-radius: 16px; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); +} + +.auth-lab-header .mud-typography-h4 { + font-size: 1.7rem; + line-height: 1.3; +} + +.auth-lab-section { + margin-bottom: 32px; +} + +.auth-lab-section-title { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; +} + + .auth-lab-section-title .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + } + + .auth-lab-section-title .mud-typography-body2 { + font-size: .78rem; + line-height: 1.6; + margin-top: 3px; + } + +.auth-lab-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.auth-lab-card { + display: flex; + align-items: flex-start; + gap: 13px; + padding: 18px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 13px; + background: var(--mud-palette-surface); +} + + .auth-lab-card > .mud-icon-root { + flex-shrink: 0; + } + +.auth-lab-card-content { + display: flex; + flex-direction: column; + gap: 7px; + flex: 1; + min-width: 0; +} + + .auth-lab-card-content strong { + font-size: .86rem; + font-weight: 750; + } + + .auth-lab-card-content span { + font-size: .75rem; + line-height: 1.6; + color: var(--mud-palette-text-secondary); + } + +a.auth-lab-card { + color: inherit; + text-decoration: none; + cursor: pointer; + transition: border-color .18s ease, background-color .18s ease, transform .18s ease; +} + + a.auth-lab-card:hover { + border-color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + transform: translateY(-2px); + } + + a.auth-lab-card:focus-visible { + outline: 2px solid var(--mud-palette-primary); + outline-offset: 2px; + } + +@media (prefers-reduced-motion: reduce) { + a.auth-lab-card { + transition: none; + } +} + +@media (max-width: 700px) { + .auth-lab-grid { + grid-template-columns: 1fr; + } + + .auth-lab-header { + align-items: flex-start; + gap: 12px; + } + + .auth-lab-header-icon { + width: 48px; + height: 48px; + } + + .auth-lab-header .mud-typography-h4 { + font-size: 1.35rem; + } + + .auth-lab-card { + padding: 15px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor new file mode 100644 index 00000000..a002051b --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor @@ -0,0 +1,351 @@ +@page "/dashboard/authorization" +@layout DashboardLayout +@inject IDialogService DialogService + +@* TODO: Enable sandbox-scoped authorization. +@attribute [UAuthAuthorize] +*@ + +Authorization · Identity Lab + +
+ + +
+
+ SANDBOX MANAGEMENT +

Authorization

+

+ Explore roles, permission assignments, + and access control within your sandbox. +

+
+ + + Create Role + +
+ +
+
+ + + + Roles + @_roles.Count + Example sandbox roles +
+ +
+ + + + Permissions + @_permissions.Count + Illustrative permissions +
+ +
+ + + + Role Assignments + 3 + Example assignments +
+
+ +
+
+

Roles & Permissions

+

+ Select a role to inspect its assigned permissions. +

+
+ + + Demo Preview + +
+ +
+ +
+
+
+

Sandbox Roles

+

Available authorization roles.

+
+ +
+ +
+ @foreach (var role in _roles) + { + + } +
+
+ +
+
+
+

@SelectedRole.Name Permissions

+

Permissions assigned to this example role.

+
+ + + Edit + +
+ + + +
+ @foreach (var permission in FilteredPermissions) + { +
+ + + + +
+ @permission.Name + @permission.Description +
+ + + @(SelectedRole.PermissionIds.Contains(permission.Id) + ? "Granted" + : "Not granted") + +
+ } + + @if (!FilteredPermissions.Any()) + { +
+ No permissions match your search. +
+ } +
+
+
+ +
+
+ AUTHORIZATION EXPLORER +

Policy Playground

+

+ Preview how role selection and access requests + can be evaluated. +

+
+
+ +
+ +
+
+
+

Access Request

+

Configure an example authorization request.

+
+ +
+ +
+ + @foreach (var role in _roles) + { + + @role.Name + + } + + + + @foreach (var permission in _permissions) + { + + @permission.Name + + } + + + + Evaluate Policy + +
+
+ +
+
+
+

Evaluation Result

+

Live authorization decisions will appear here.

+
+ +
+ +
+ + + + Ready to evaluate +

+ Connect UltimateAuth authorization to inspect + actual access decisions for sandbox users. +

+
+ + + A role's permission list alone does not guarantee + an authorization decision. Policies and context + may also affect the result. + +
+ +
+ +
+
+ +@code { + private string _permissionSearch = string.Empty; + private string _playgroundRoleId = "owner"; + private string _playgroundPermissionId = "users.query"; + + private async Task OpenRoleManagementAsync() + { + var parameters = new DialogParameters + { + { x => x.PreviewMode, true } + }; + + await DialogService.ShowAsync( + "Role Management", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Large)); + } + + private readonly List _permissions = + [ + new("users.query", "users.query.self", "View own user information"), + new("users.manage", "users.update.admin", "Manage sandbox users"), + new("sessions.query", "sessions.query.self", "Inspect own sessions"), + new("sessions.revoke", "sessions.revoke.self", "Revoke own sessions"), + new("roles.manage", "roles.update.admin", "Manage role assignments"), + new("credentials.update", "credentials.update.self", "Update own credentials") + ]; + + private readonly List _roles = + [ + new( + "owner", + "Owner", + "Sandbox administration", + 1, + true, + ["users.query", "users.manage", "sessions.query", + "sessions.revoke", "roles.manage", "credentials.update"] + ), + new( + "member", + "Member", + "Personal identity access", + 2, + false, + ["users.query", "sessions.query", + "sessions.revoke", "credentials.update"] + ) + ]; + + private RolePreview SelectedRole => + _roles.First(x => x.Id == _selectedRoleId); + + private string _selectedRoleId = "owner"; + + private IEnumerable FilteredPermissions => + _permissions.Where(x => + string.IsNullOrWhiteSpace(_permissionSearch) || + x.Name.Contains(_permissionSearch, StringComparison.OrdinalIgnoreCase) || + x.Description.Contains(_permissionSearch, StringComparison.OrdinalIgnoreCase)); + + private void SelectRole(RolePreview role) + { + _selectedRoleId = role.Id; + } + + private sealed record PermissionPreview( + string Id, + string Name, + string Description); + + private sealed record RolePreview( + string Id, + string Name, + string Description, + int MemberCount, + bool IsOwner, + string[] PermissionIds); +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor.css new file mode 100644 index 00000000..ed4dcef6 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/AuthorizationPage.razor.css @@ -0,0 +1,324 @@ +.lab-authz-page { + padding: 42px 0 80px; +} + +.lab-authz-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 30px; +} + +.lab-authz-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1.2px; +} + +.lab-authz-heading h1 { + margin: 0; + font-size: clamp(1.7rem, 3vw, 2.15rem); + font-weight: 800; + letter-spacing: -1px; +} + +.lab-authz-heading p, +.lab-authz-section-heading p { + margin: 8px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .84rem; + line-height: 1.6; +} + +.lab-authz-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + margin-bottom: 24px; +} + +.lab-authz-stat, +.lab-authz-card { + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 16px; +} + +.lab-authz-stat { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 23px; +} + +.lab-authz-stat-icon, +.lab-authz-role-icon, +.lab-authz-permission-icon, +.lab-authz-result-empty > span { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 43px; + height: 43px; + border-radius: 12px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-authz-stat > span:nth-child(2) { + margin-top: 17px; + color: var(--mud-palette-text-secondary); + font-size: .76rem; +} + +.lab-authz-stat strong { + margin-top: 5px; + font-size: 1.5rem; + font-weight: 800; +} + +.lab-authz-stat small { + margin-top: 5px; + color: var(--mud-palette-text-secondary); + font-size: .72rem; +} + +.lab-authz-section-heading { + margin: 34px 0 19px; +} + + .lab-authz-section-heading h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 750; + letter-spacing: -.4px; + } + +.lab-authz-main-grid { + display: grid; + grid-template-columns: minmax(280px, .8fr) minmax(0, 1.4fr); + gap: 20px; + align-items: start; +} + +.lab-authz-card { + min-width: 0; + padding: 25px; +} + +.lab-authz-card-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 23px; +} + + .lab-authz-card-heading h3 { + margin: 0; + font-size: 1rem; + font-weight: 750; + } + + .lab-authz-card-heading p { + margin: 5px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .76rem; + line-height: 1.5; + } + +.lab-authz-role-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.lab-authz-role { + display: flex; + align-items: center; + gap: 13px; + width: 100%; + padding: 15px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + background: transparent; + color: var(--mud-palette-text-primary); + text-align: left; + cursor: pointer; + transition: border-color .2s ease, background .2s ease; +} + + .lab-authz-role:hover { + border-color: var(--mud-palette-primary); + } + + .lab-authz-role.selected { + border-color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + } + +.lab-authz-role-content { + display: flex; + flex: 1; + flex-direction: column; + gap: 5px; + min-width: 0; +} + + .lab-authz-role-content strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-authz-role-content small, + .lab-authz-role-content > span { + color: var(--mud-palette-text-secondary); + font-size: .72rem; + line-height: 1.5; + } + +.lab-authz-permission-list { + display: flex; + flex-direction: column; +} + +.lab-authz-permission { + display: flex; + align-items: center; + gap: 12px; + padding: 15px 0; + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-authz-permission-icon { + width: 36px; + height: 36px; + border-radius: 10px; +} + +.lab-authz-permission > div { + display: flex; + flex: 1; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.lab-authz-permission strong { + font-size: .8rem; + font-weight: 700; + overflow-wrap: anywhere; +} + +.lab-authz-permission span { + color: var(--mud-palette-text-secondary); + font-size: .72rem; +} + +.lab-authz-empty { + padding: 35px 10px; + text-align: center; + color: var(--mud-palette-text-secondary); + font-size: .8rem; +} + +.lab-authz-playground-heading { + margin-top: 48px; +} + +.lab-authz-playground-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + align-items: stretch; +} + +.lab-authz-fields { + display: flex; + flex-direction: column; + gap: 20px; +} + +.lab-authz-result-card { + display: flex; + flex-direction: column; +} + +.lab-authz-result-empty { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 12px; + text-align: center; +} + + .lab-authz-result-empty > span { + width: 58px; + height: 58px; + border-radius: 17px; + margin-bottom: 18px; + } + + .lab-authz-result-empty strong { + font-size: .92rem; + font-weight: 750; + } + + .lab-authz-result-empty p { + max-width: 290px; + margin: 9px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .77rem; + line-height: 1.7; + } + +@media (max-width: 1100px) { + .lab-authz-main-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .lab-authz-page { + padding: 28px 0 52px; + } + + .lab-authz-heading { + flex-direction: column; + } + + .lab-authz-stats, + .lab-authz-playground-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .lab-authz-section-heading { + margin-top: 30px; + } + + .lab-authz-playground-heading { + margin-top: 36px; + } +} + +@media (max-width: 600px) { + .lab-authz-card { + padding: 20px; + } + + .lab-authz-permission { + flex-wrap: wrap; + } + + .lab-authz-role { + padding: 12px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor new file mode 100644 index 00000000..4a7af9ae --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor @@ -0,0 +1,290 @@ +@page "/dashboard/access" +@layout DashboardLayout + +@* TODO: Enable after authentication integration. +@attribute [UAuthAuthorize] +*@ + +My Access · Identity Lab + +
+ + +
+
+ PERSONAL AUTHORIZATION +

My Access

+

+ Explore your roles, effective permissions, + and how UltimateAuth evaluates access. +

+
+ + + Demo Preview + +
+ +
+
+ + + + Assigned Roles + 1 + Example role assignment +
+ +
+ + + + Effective Permissions + 4 + Illustrative permission set +
+ +
+ + + + Access Context + Member + Example sandbox role +
+
+ +
+ +
+
+
+

My Roles

+

Roles assigned to your identity.

+
+ +
+ +
+ + + + +
+ Member + Example sandbox membership role +
+ + + Assigned + +
+ + + Your roles determine which operations you may + perform, together with the applicable policies + and authorization context. + +
+ +
+
+
+

Effective Permissions

+

Example permissions available to your identity.

+
+ +
+ +
+ @foreach (var permission in _permissions) + { +
+ + + + +
+ @permission.Name + @permission.Description +
+ + + Allowed + +
+ } +
+
+
+ +
+ INTERACTIVE AUTHORIZATION +

Authorization Playground

+

+ Choose a resource and operation to explore + how access decisions work. +

+
+ +
+ +
+
+
+

Access Request

+

Configure an authorization check.

+
+ +
+ +
+ + Users + Sessions + Credentials + + + + Query + Create + Update + Revoke + + + + + + Evaluate Access + +
+
+ +
+
+
+

Evaluation Result

+

Authorization decision and explanation.

+
+ +
+ +
+ + + + + Ready to evaluate +

+ Live access decisions will appear here once + UltimateAuth authorization is connected. +

+
+ + +
+ +
+ +
+ ULTIMATEAUTH CONCEPTS +

Understanding Access Control

+

+ Three concepts work together to protect resources. +

+
+ +
+
+ + + + Roles +

+ Group authorization responsibilities and + organize access assignments. +

+
+ +
+ + + + Permissions +

+ Represent actions that an identity may be + authorized to perform. +

+
+ +
+ + + + Policies +

+ Evaluate authorization requirements using + the relevant access context. +

+
+
+ +
+
+ +@code { + private string _selectedResource = "users"; + private string _selectedOperation = "query"; + + private readonly List _permissions = + [ + new("users.query.self", "View your own user information"), + new("sessions.query.self", "Inspect your own sessions"), + new("credentials.update.self", "Manage your own credentials"), + new("sessions.revoke.self", "Revoke your own sessions") + ]; + + private sealed record PermissionPreview( + string Name, + string Description); +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor.css new file mode 100644 index 00000000..fa1f2767 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyAccessPage.razor.css @@ -0,0 +1,311 @@ +.lab-access-page { + padding: 42px 0 80px; +} + +.lab-access-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 30px; +} + +.lab-access-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1.2px; +} + +.lab-access-heading h1 { + margin: 0; + font-size: clamp(1.7rem, 3vw, 2.15rem); + font-weight: 800; + letter-spacing: -1px; +} + +.lab-access-heading p, +.lab-access-section-heading p { + margin: 8px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .84rem; + line-height: 1.6; +} + +.lab-access-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + margin-bottom: 22px; +} + +.lab-access-stat, +.lab-access-card, +.lab-concept-card { + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 16px; +} + +.lab-access-stat { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 23px; +} + +.lab-access-stat-icon, +.lab-role-icon, +.lab-concept-icon, +.lab-result-empty-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: 12px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-access-stat > span:nth-child(2) { + margin-top: 18px; + color: var(--mud-palette-text-secondary); + font-size: .77rem; +} + +.lab-access-stat strong { + margin-top: 5px; + font-size: 1.5rem; + font-weight: 800; + letter-spacing: -.5px; +} + +.lab-access-stat small { + margin-top: 5px; + color: var(--mud-palette-text-secondary); + font-size: .72rem; +} + +.lab-access-grid, +.lab-playground-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + align-items: stretch; +} + +.lab-access-card { + min-width: 0; + padding: 26px; +} + +.lab-access-card-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + + .lab-access-card-heading h2 { + margin: 0; + font-size: 1rem; + font-weight: 750; + letter-spacing: -.3px; + } + + .lab-access-card-heading p { + margin: 5px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .76rem; + line-height: 1.5; + } + +.lab-role-item, +.lab-permission-item { + display: flex; + align-items: center; + gap: 13px; + padding: 17px 0; + border-top: 1px solid var(--mud-palette-lines-default); +} + + .lab-role-item > div, + .lab-permission-item > div { + display: flex; + flex: 1; + flex-direction: column; + gap: 5px; + min-width: 0; + } + + .lab-role-item strong, + .lab-permission-item strong { + font-size: .82rem; + font-weight: 700; + overflow-wrap: anywhere; + } + + .lab-role-item span, + .lab-permission-item span { + color: var(--mud-palette-text-secondary); + font-size: .72rem; + } + +.lab-permission-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 30px; + height: 30px; + border-radius: 9px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-access-section-heading { + margin: 48px 0 20px; +} + + .lab-access-section-heading h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 750; + letter-spacing: -.4px; + } + +.lab-playground-fields { + display: flex; + flex-direction: column; + gap: 20px; +} + +.lab-result-card { + display: flex; + flex-direction: column; +} + +.lab-result-empty { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 12px; + text-align: center; +} + +.lab-result-empty-icon { + width: 58px; + height: 58px; + border-radius: 17px; + margin-bottom: 18px; +} + +.lab-result-empty strong { + font-size: .92rem; + font-weight: 750; +} + +.lab-result-empty p { + max-width: 290px; + margin: 9px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .77rem; + line-height: 1.7; +} + +.lab-result-footer { + display: flex; + align-items: flex-start; + gap: 9px; + padding-top: 17px; + border-top: 1px solid var(--mud-palette-lines-default); + color: var(--mud-palette-text-secondary); + font-size: .72rem; + line-height: 1.6; +} + + .lab-result-footer .mud-icon-root { + flex-shrink: 0; + } + +.lab-model-heading { + margin-top: 48px; +} + +.lab-access-concepts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.lab-concept-card { + padding: 25px; +} + + .lab-concept-card strong { + display: block; + margin-top: 20px; + font-size: .93rem; + font-weight: 750; + } + + .lab-concept-card p { + margin: 9px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .77rem; + line-height: 1.7; + } + +@media (max-width: 960px) { + .lab-access-grid, + .lab-playground-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .lab-access-page { + padding: 28px 0 52px; + } + + .lab-access-heading { + flex-direction: column; + } + + .lab-access-stats { + grid-template-columns: 1fr; + gap: 12px; + } + + .lab-access-concepts { + grid-template-columns: 1fr; + gap: 12px; + } + + .lab-access-section-heading { + margin-top: 36px; + } +} + +@media (max-width: 600px) { + .lab-access-card, + .lab-concept-card { + padding: 20px; + } + + .lab-role-item, + .lab-permission-item { + flex-wrap: wrap; + } + + .lab-result-empty { + padding: 25px 8px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor new file mode 100644 index 00000000..0d8c2f48 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor @@ -0,0 +1,334 @@ +@page "/dashboard/identity" +@layout DashboardLayout +@inject IDialogService DialogService + +@* TODO: Enable after authentication integration. +@attribute [UAuthAuthorize] +*@ + +My Identity · Identity Lab + +
+ + +
+
+ PERSONAL WORKSPACE +

My Identity

+

Explore your account, sign-in identifiers, and credential security.

+
+ + + Demo Preview + +
+ +
+
+
+
+
+

Profile

+

Your account information.

+
+ + +
+ +
+ + + + +
+ Sandbox User + @@demo-user +
+ + + + + Active + +
+ +
+
+ Display name + Sandbox User +
+ +
+ Username + demo-user +
+ +
+ Email + demo@example.com +
+ +
+ Account status + Active +
+
+ + + Edit Profile + +
+ +
+ +
+
+

Sign-in Identifiers

+

+ Identifiers associated with your account. +

+
+ + +
+ +
+ +
+ + + + +
+ demo@example.com + Email address +
+ + + Verified + +
+ +
+ + + + +
+ demo-user + Username +
+ + + Primary + +
+ +
+ + + Add Identifier + + +
+
+ +
+ +
+ +
+
+

Credentials & Security

+

+ Manage how you authenticate. +

+
+ + +
+ +
+ + + + +
+ Password +

+ Update the secret used to sign in. +

+
+ + + Change + +
+ +
+ + + + +
+ Device Sessions +

+ Review and revoke your sessions. +

+
+ + +
+ +
+ + + + +
+ My Authorization +

+ Inspect your assigned roles and permissions. +

+
+ + +
+ +
+ +
+ +
+
+

Authentication Context

+

+ Understand your UltimateAuth identity. +

+
+ + +
+ +
+
+ + + + +
+ Identity + Your authenticated account +
+
+ +
+ +
+ + + + +
+ Authentication Chain + Explore your authentication lifecycle +
+
+ +
+ +
+ + + + +
+ Sessions + Manage active device sessions +
+
+
+ + + This is an illustrative overview. + Live authentication data will appear after integration. + + +
+ +
+
+ +
+
+ +@code { + private async Task OpenEditProfileAsync() + { + var parameters = new DialogParameters + { + { x => x.PreviewMode, true }, + { x => x.PreviewDisplayName, "Alex Morgan" } + }; + + await DialogService.ShowAsync( + "Edit Profile", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Medium)); + } + + private async Task OpenIdentifiersAsync() + { + var parameters = new DialogParameters + { + { x => x.PreviewMode, true } + }; + + await DialogService.ShowAsync( + "Manage Identifiers", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Medium)); + } + + private async Task OpenChangePasswordAsync() + { + var parameters = new DialogParameters + { + { x => x.PreviewMode, true } + }; + + await DialogService.ShowAsync( + "Change Password", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Small)); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor.css new file mode 100644 index 00000000..33021912 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MyIdentityPage.razor.css @@ -0,0 +1,240 @@ +.lab-identity-page { + padding: 42px 0 80px; +} + +.lab-identity-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 32px; +} + +.lab-identity-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1.2px; +} + +.lab-identity-header h1 { + margin: 0; + font-size: clamp(1.7rem, 3vw, 2.15rem); + font-weight: 800; + letter-spacing: -1px; +} + +.lab-identity-header p { + margin: 9px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .85rem; + line-height: 1.6; +} + +.lab-identity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px; + align-items: start; +} + +.lab-identity-column { + display: flex; + flex-direction: column; + gap: 22px; + min-width: 0; +} + +.lab-identity-card { + padding: 26px; + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 17px; +} + +.lab-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + margin-bottom: 24px; +} + + .lab-card-header h2 { + margin: 0; + font-size: 1rem; + font-weight: 750; + letter-spacing: -.3px; + } + + .lab-card-header p { + margin: 5px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .76rem; + line-height: 1.5; + } + +.lab-identity-profile { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 23px; +} + + .lab-identity-profile > div:nth-child(2), + .lab-identifier-info { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + } + + .lab-identity-profile strong, + .lab-identifier-info strong { + font-size: .85rem; + font-weight: 700; + overflow-wrap: anywhere; + } + + .lab-identity-profile span, + .lab-identifier-info span { + color: var(--mud-palette-text-secondary); + font-size: .73rem; + } + +.lab-identity-details { + margin-bottom: 20px; +} + +.lab-identity-detail { + display: flex; + justify-content: space-between; + gap: 15px; + padding: 13px 0; + border-top: 1px solid var(--mud-palette-lines-default); + font-size: .8rem; +} + + .lab-identity-detail > span { + color: var(--mud-palette-text-secondary); + } + + .lab-identity-detail strong { + font-weight: 650; + text-align: right; + overflow-wrap: anywhere; + } + +.lab-identifier-list { + margin-bottom: 22px; +} + +.lab-identifier-item { + display: flex; + align-items: center; + gap: 13px; + padding: 16px 0; + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-identifier-info { + flex: 1; +} + +.lab-identifier-icon, +.lab-security-icon, +.lab-auth-context-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 42px; + height: 42px; + border-radius: 12px; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); +} + +.lab-security-item { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 0; + border-top: 1px solid var(--mud-palette-lines-default); +} + + .lab-security-item > div:nth-child(2) { + flex: 1; + min-width: 0; + } + + .lab-security-item strong { + font-size: .83rem; + font-weight: 700; + } + + .lab-security-item p { + margin: 5px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .73rem; + line-height: 1.5; + } + +.lab-auth-context { + padding: 8px 0; +} + +.lab-auth-context-node { + display: flex; + align-items: center; + gap: 13px; +} + + .lab-auth-context-node > div:last-child { + display: flex; + flex-direction: column; + gap: 5px; + } + + .lab-auth-context-node strong { + font-size: .82rem; + font-weight: 700; + } + + .lab-auth-context-node span { + color: var(--mud-palette-text-secondary); + font-size: .72rem; + } + +.lab-auth-context-connector { + width: 2px; + height: 24px; + margin: 5px 0 5px 20px; + background: var(--mud-palette-lines-default); +} + +@media (max-width: 960px) { + .lab-identity-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 600px) { + .lab-identity-page { + padding: 28px 0 52px; + } + + .lab-identity-header { + flex-direction: column; + } + + .lab-identity-card { + padding: 20px; + } + + .lab-security-item { + gap: 10px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor new file mode 100644 index 00000000..fd77d9db --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor @@ -0,0 +1,386 @@ +@page "/dashboard/sessions" +@layout DashboardLayout +@inject IDialogService DialogService + +@* TODO: Enable after authentication integration. +@attribute [UAuthAuthorize] +*@ + +My Sessions · Identity Lab + +
+ + +
+
+ PERSONAL SECURITY +

My Sessions

+

+ Explore your devices, authentication sessions, + and the UltimateAuth session lifecycle. +

+
+ + + Demo Preview + +
+ +
+
+ + + + Active sessions + 2 + Example session count +
+ +
+ + + + Authentication chains + 2 + Illustrative chains +
+ +
+ + + + Security status + Active + Example state +
+
+ +
+
+

Active Devices

+

+ Sessions associated with your identity. +

+
+ + + Revoke Other Sessions + +
+ +
+ +
+ + + + +
+
+ Windows · Chrome + + + Current Device + +
+ +

Example desktop session

+ +
+ + + Active now (example) + + + + Password authentication + +
+
+ + + Details + +
+ +
+ + + + +
+
+ Mobile · Safari + + + Remote Session + +
+ +

Example mobile session

+ +
+ + + Recently active (example) + + + + Password authentication + +
+
+ + + Revoke + +
+ +
+ +
+
+ ULTIMATEAUTH ARCHITECTURE +

Authentication Hierarchy

+

+ Understand the relationship between authentication + roots, chains, and sessions. +

+
+
+ +
+ +
+ +
+

Session Structure

+ +
+ +
+ +
+ + + + +
+ Authentication Root + Example authentication origin +
+ + + Active + +
+ +
+ +
+ + + + +
+ Chain A + Example desktop chain +
+
+ +
+ + +
+ Session A1 + Windows · Chrome +
+ + + Current + +
+ +
+ + + + +
+ Chain B + Example mobile chain +
+
+ +
+ + +
+ Session B1 + Mobile · Safari +
+
+ +
+
+ +
+ +
+ +
+

How It Works

+ +
+ +
+ +
+ 01 +
+ Root +

+ Represents the origin of an authentication + relationship. +

+
+
+ +
+ 02 +
+ Chain +

+ Groups related authentication activity + within a lifecycle. +

+
+
+ +
+ 03 +
+ Session +

+ Represents a session that can be inspected + and managed according to its security context. +

+
+
+ +
+ + + This hierarchy is illustrative. The actual structure + depends on the authentication mode and session lifecycle. + + +
+ +
+ +
+
+

Session Security

+

+ Manage your authentication lifecycle and device access. +

+
+
+ +
+ +
+ + + +
+ Revoke other sessions +

+ End other device sessions while keeping + your current session. +

+
+ + Revoke Others + +
+ +
+ + + +
+ Sign out +

+ End your current authenticated session. +

+
+ + Sign Out + +
+ +
+ +
+
+ +@code { + private async Task OpenSessionManagementAsync() + { + var parameters = new DialogParameters + { + { x => x.PreviewMode, true } + }; + + await DialogService.ShowAsync( + "Session Management", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Large)); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor.css new file mode 100644 index 00000000..dd58731e --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/MySessionsPage.razor.css @@ -0,0 +1,404 @@ +.lab-sessions-page { + padding: 42px 0 80px; +} + +.lab-sessions-heading, +.lab-sessions-section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.lab-sessions-heading { + margin-bottom: 30px; +} + +.lab-sessions-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1.2px; +} + +.lab-sessions-heading h1 { + margin: 0; + font-size: clamp(1.7rem, 3vw, 2.15rem); + font-weight: 800; + letter-spacing: -1px; +} + +.lab-sessions-heading p, +.lab-sessions-section-heading p { + margin: 8px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .83rem; + line-height: 1.6; +} + +.lab-sessions-section-heading { + align-items: center; + margin: 34px 0 19px; +} + + .lab-sessions-section-heading h2 { + margin: 0; + font-size: 1.12rem; + font-weight: 750; + letter-spacing: -.4px; + } + +.lab-sessions-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.lab-session-stat, +.lab-sessions-panel, +.lab-device-card, +.lab-session-actions { + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 16px; +} + +.lab-session-stat { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 23px; +} + +.lab-session-stat-icon, +.lab-device-icon, +.lab-session-action-icon, +.lab-tree-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: 12px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-session-stat > span:nth-child(2) { + margin-top: 18px; + font-size: .77rem; + color: var(--mud-palette-text-secondary); +} + +.lab-session-stat strong { + margin-top: 5px; + font-size: 1.5rem; + font-weight: 800; + letter-spacing: -.5px; +} + +.lab-session-stat small { + margin-top: 5px; + font-size: .72rem; + color: var(--mud-palette-text-secondary); +} + +.lab-device-list { + display: flex; + flex-direction: column; + gap: 13px; +} + +.lab-device-card { + display: flex; + align-items: center; + gap: 20px; + padding: 23px; +} + +.lab-device-main { + flex: 1; + min-width: 0; +} + +.lab-device-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + + .lab-device-title strong { + font-size: .92rem; + font-weight: 750; + } + +.lab-device-main > p { + margin: 5px 0 12px; + color: var(--mud-palette-text-secondary); + font-size: .76rem; +} + +.lab-device-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; +} + + .lab-device-meta span { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--mud-palette-text-secondary); + font-size: .72rem; + } + +.lab-sessions-hierarchy-heading { + margin-top: 48px; +} + +.lab-hierarchy-layout { + display: grid; + grid-template-columns: 1.15fr 1fr; + gap: 20px; + align-items: stretch; +} + +.lab-sessions-panel { + min-width: 0; + padding: 26px; +} + +.lab-panel-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 26px; +} + + .lab-panel-title h3 { + margin: 0; + font-size: .98rem; + font-weight: 750; + } + +.lab-hierarchy-tree { + padding: 4px 0; +} + +.lab-tree-node, +.lab-tree-leaf { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + + .lab-tree-node > div, + .lab-tree-leaf > div { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .lab-tree-node strong, + .lab-tree-leaf strong { + font-size: .82rem; + font-weight: 700; + } + + .lab-tree-node span, + .lab-tree-leaf span { + font-size: .71rem; + color: var(--mud-palette-text-secondary); + } + +.lab-tree-root { + padding: 12px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; +} + +.lab-tree-branch { + margin: 0 0 0 32px; + padding: 20px 0 0 24px; + border-left: 2px solid var(--mud-palette-lines-default); +} + + .lab-tree-branch > .lab-tree-node { + position: relative; + margin-bottom: 12px; + } + + .lab-tree-branch > .lab-tree-node::before { + content: ""; + position: absolute; + left: -24px; + top: 22px; + width: 20px; + height: 2px; + background: var(--mud-palette-lines-default); + } + +.lab-tree-leaf { + margin: 0 0 25px 21px; + padding: 13px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); +} + + .lab-tree-leaf:last-child { + margin-bottom: 0; + } + +.lab-explanation-list { + display: flex; + flex-direction: column; + gap: 23px; +} + +.lab-explanation-item { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.lab-explanation-number { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 35px; + height: 35px; + border-radius: 10px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); + font-size: .72rem; + font-weight: 800; +} + +.lab-explanation-item strong { + font-size: .85rem; + font-weight: 750; +} + +.lab-explanation-item p { + margin: 5px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .76rem; + line-height: 1.65; +} + +.lab-sessions-security-heading { + margin-top: 48px; +} + +.lab-session-actions { + overflow: hidden; +} + +.lab-session-action { + display: flex; + align-items: center; + gap: 17px; + padding: 22px 25px; +} + + .lab-session-action + .lab-session-action { + border-top: 1px solid var(--mud-palette-lines-default); + } + + .lab-session-action > div:nth-child(2) { + flex: 1; + min-width: 0; + } + + .lab-session-action strong { + font-size: .86rem; + font-weight: 750; + } + + .lab-session-action p { + margin: 5px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .75rem; + line-height: 1.5; + } + +@media (max-width: 1100px) { + .lab-hierarchy-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .lab-sessions-page { + padding: 28px 0 52px; + } + + .lab-sessions-heading, + .lab-sessions-section-heading { + flex-direction: column; + align-items: flex-start; + } + + .lab-sessions-stats { + grid-template-columns: 1fr; + gap: 12px; + } + + .lab-device-card { + flex-wrap: wrap; + gap: 14px; + padding: 19px; + } + + .lab-device-main { + flex-basis: calc(100% - 65px); + } + + .lab-device-card > .mud-button { + margin-left: auto; + } + + .lab-session-action { + flex-wrap: wrap; + padding: 20px; + } + + .lab-session-action > div:nth-child(2) { + flex-basis: calc(100% - 65px); + } + + .lab-session-action > .mud-button { + margin-left: auto; + } +} + +@media (max-width: 480px) { + .lab-sessions-panel { + padding: 18px; + } + + .lab-tree-branch { + margin-left: 16px; + padding-left: 16px; + } + + .lab-tree-branch > .lab-tree-node::before { + left: -16px; + width: 13px; + } + + .lab-tree-leaf { + margin-left: 8px; + padding: 10px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor new file mode 100644 index 00000000..78af858c --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor @@ -0,0 +1,285 @@ +@page "/dashboard/settings" +@layout DashboardLayout + +@* TODO: Enable sandbox-owner authorization. +@attribute [UAuthAuthorize] +*@ + +Sandbox Settings · Identity Lab + +
+ + +
+
+ SANDBOX MANAGEMENT +

Sandbox Settings

+

+ Review your sandbox configuration, + membership limits, and security boundaries. +

+
+ + + Demo Preview + +
+ +
+ +
+ +
+
+
+

General

+

Basic information about your sandbox.

+
+ +
+ +
+ + + + + + + + Save Changes + +
+
+ +
+
+
+

Membership & Capacity

+

Available identities in this environment.

+
+ +
+ +
+
+ Occupied user slots + 3 / 4 +
+ + + +

+ Your sandbox supports up to four accounts, + including the owner. +

+
+ +
+ Maximum Users + 4 +
+ +
+ Available Slots + 1 +
+ +
+ Membership Model + Owner + Members +
+ + + Manage Users + +
+ +
+ +
+ +
+
+
+

Security Boundaries

+

Rules that protect sandbox isolation.

+
+ +
+ +
+ +
+ + + +
+ Private Sandbox +

+ Users and resources are scoped to + their own sandbox. +

+
+ +
+ +
+ + + +
+ Scoped Administration +

+ Owners manage only identities and + resources within their sandbox. +

+
+ +
+ +
+ + + +
+ Membership Limit +

+ A maximum of four accounts prevents + unrestricted sandbox growth. +

+
+ +
+ +
+ + + These are planned Identity Lab security rules. + They must be enforced server-side before launch. + +
+ +
+
+
+

Environment

+

Information about this Identity Lab instance.

+
+ +
+ +
+ Platform + UltimateAuth +
+ +
+ Application + Identity Lab +
+ +
+ Hosting Model + Blazor Server +
+ +
+ Source + + GitHub Repository + +
+
+ +
+ +
+ +
+
+
+

Danger Zone

+

Destructive operations affecting your sandbox.

+
+ +
+ +
+
+ Reset Sandbox +

+ Remove sandbox-created resources and restore + the environment to its initial configuration. +

+
+ + + Reset Sandbox + +
+ +
+
+ Delete Sandbox +

+ Permanently delete the sandbox and its associated + application resources. +

+
+ + + Delete Sandbox + +
+
+ +
+
diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor.css new file mode 100644 index 00000000..06c76b0d --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/SettingsPage.razor.css @@ -0,0 +1,240 @@ +.lab-settings-page { + padding: 42px 0 80px; +} + +.lab-settings-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 30px; +} + +.lab-settings-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1.2px; +} + +.lab-settings-heading h1 { + margin: 0; + font-size: clamp(1.7rem, 3vw, 2.15rem); + font-weight: 800; + letter-spacing: -1px; +} + +.lab-settings-heading p { + margin: 9px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .84rem; + line-height: 1.6; +} + +.lab-settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px; + align-items: start; +} + +.lab-settings-column { + display: flex; + flex-direction: column; + gap: 22px; + min-width: 0; +} + +.lab-settings-card { + padding: 26px; + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 17px; +} + +.lab-settings-card-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 15px; + margin-bottom: 24px; +} + + .lab-settings-card-heading h2 { + margin: 0; + font-size: 1rem; + font-weight: 750; + letter-spacing: -.3px; + } + + .lab-settings-card-heading p { + margin: 5px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .76rem; + line-height: 1.6; + } + +.lab-settings-fields { + display: flex; + flex-direction: column; + gap: 20px; +} + + .lab-settings-fields > .mud-button { + align-self: flex-start; + } + +.lab-settings-capacity { + padding: 18px; + margin-bottom: 15px; + border-radius: 12px; + background: var(--mud-palette-background); +} + +.lab-settings-capacity-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + + .lab-settings-capacity-header span { + color: var(--mud-palette-text-secondary); + font-size: .77rem; + } + + .lab-settings-capacity-header strong { + font-size: .94rem; + font-weight: 800; + } + +.lab-settings-capacity p { + margin: 0; + color: var(--mud-palette-text-secondary); + font-size: .73rem; + line-height: 1.6; +} + +.lab-settings-detail-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + padding: 15px 0; + border-top: 1px solid var(--mud-palette-lines-default); +} + + .lab-settings-detail-row > span { + color: var(--mud-palette-text-secondary); + font-size: .78rem; + } + + .lab-settings-detail-row strong { + font-size: .8rem; + font-weight: 700; + text-align: right; + } + +.lab-settings-security-list { + display: flex; + flex-direction: column; +} + +.lab-settings-security-item { + display: flex; + align-items: flex-start; + gap: 13px; + padding: 18px 0; + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-settings-security-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-settings-security-item > div { + flex: 1; + min-width: 0; +} + +.lab-settings-security-item strong { + font-size: .83rem; + font-weight: 700; +} + +.lab-settings-security-item p { + margin: 6px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .73rem; + line-height: 1.6; +} + +.lab-settings-danger { + margin-top: 22px; + border-color: var(--mud-palette-error); +} + + .lab-settings-danger .lab-settings-card-heading h2 { + color: var(--mud-palette-error); + } + +.lab-settings-danger-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 20px 0; + border-top: 1px solid var(--mud-palette-lines-default); +} + + .lab-settings-danger-action > div { + flex: 1; + } + + .lab-settings-danger-action strong { + font-size: .84rem; + font-weight: 700; + } + + .lab-settings-danger-action p { + margin: 6px 0 0; + max-width: 540px; + color: var(--mud-palette-text-secondary); + font-size: .75rem; + line-height: 1.6; + } + +@media (max-width: 960px) { + .lab-settings-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 600px) { + .lab-settings-page { + padding: 28px 0 52px; + } + + .lab-settings-heading { + flex-direction: column; + } + + .lab-settings-card { + padding: 20px; + } + + .lab-settings-danger-action { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor new file mode 100644 index 00000000..79bbd06c --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor @@ -0,0 +1,355 @@ +@page "/dashboard/users" +@layout DashboardLayout +@inject IDialogService DialogService + +@* TODO: Enable sandbox-scoped authorization. +@attribute [UAuthAuthorize] +*@ + +Users · Identity Lab + +
+ + +
+
+ SANDBOX MANAGEMENT +

Users

+

+ Manage identities, account status, and + membership within your sandbox. +

+
+ + + Create User + +
+ +
+ +
+
+
+ Sandbox Capacity + @_users.Count / @MaxUsers users +
+ + + Demo Preview + +
+ + + +

+ @_users.Count of @MaxUsers available user slots are + occupied, including the sandbox owner. +

+
+ +
+ + + + Total Users + @_users.Count + Example sandbox members +
+ +
+ + + + Active Users + @_users.Count(x => x.Status == UserStatus.Active) + Example active accounts +
+ +
+ +
+ +
+
+

Sandbox Members

+

+ Browse and manage the identities + belonging to this environment. +

+
+ + + @FilteredUsers.Count() results + +
+ +
+ + + + All statuses + Active + Disabled + +
+ + + + + User + Role + Status + Created + Actions + + + + +
+ + @context.Initials + + +
+ @context.DisplayName + @context.Email + @@@context.Username +
+
+
+ + + + @(context.IsOwner ? "Owner" : "Member") + + + + + + @context.Status + + + + + + @context.CreatedAt.ToString("MMM dd, yyyy") + + + + + + +
+ + +
+ + No users found +

Try changing your search or status filter.

+
+
+ + + + + +
+ +
+ +
+ +
+ + +
+ Sandbox isolation +

+ Members belong to a private sandbox. + User management must be scoped to that sandbox, + including all authorization and data operations. +

+
+
+ + + Explore Authorization + + +
+ +
+
+ +@code { + private const int MaxUsers = 4; + + private string _search = string.Empty; + private string _statusFilter = "all"; + + private double CapacityPercentage => (double)_users.Count / MaxUsers * 100; + + + private async Task OpenCreateUserDialogAsync() + { + var parameters = new DialogParameters + { + { x => x.PreviewMode, true } + }; + + await DialogService.ShowAsync("Create User", parameters, UAuthDialog.GetDialogOptions(MaxWidth.Small)); + } + + private async Task OpenUserProfileAsync(string displayName) + { + var parameters = new DialogParameters + { + { x => x.PreviewMode, true }, + { x => x.PreviewDisplayName, displayName } + }; + + await DialogService.ShowAsync( + "Edit Profile", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Medium)); + } + + + private IEnumerable FilteredUsers => + _users.Where(user => + { + var matchesSearch = + string.IsNullOrWhiteSpace(_search) || + user.DisplayName.Contains(_search, StringComparison.OrdinalIgnoreCase) || + user.Username.Contains(_search, StringComparison.OrdinalIgnoreCase) || + user.Email.Contains(_search, StringComparison.OrdinalIgnoreCase); + + var matchesStatus = _statusFilter switch + { + "active" => user.Status == UserStatus.Active, + "disabled" => user.Status == UserStatus.Disabled, + _ => true + }; + + return matchesSearch && matchesStatus; + }); + + private readonly List _users = + [ + new( + "Alex Morgan", + "alex", + "alex@example.com", + true, + UserStatus.Active, + new DateTime(2026, 10, 18) + ), + new( + "Taylor Reed", + "taylor", + "taylor@example.com", + false, + UserStatus.Active, + new DateTime(2026, 10, 19) + ), + new( + "Jordan Lee", + "jordan", + "jordan@example.com", + false, + UserStatus.Disabled, + new DateTime(2026, 10, 20) + ) + ]; + + private enum UserStatus + { + Active, + Disabled + } + + private sealed record SandboxUserPreview( + string DisplayName, + string Username, + string Email, + bool IsOwner, + UserStatus Status, + DateTime CreatedAt) + { + public string Initials => + string.Concat( + DisplayName.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Take(2) + .Select(x => char.ToUpperInvariant(x[0]))); + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor.css new file mode 100644 index 00000000..21c2e2a3 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Dashboard/UsersPage.razor.css @@ -0,0 +1,321 @@ +.lab-users-page { + padding: 42px 0 80px; +} + +.lab-users-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 30px; +} + +.lab-users-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1.2px; +} + +.lab-users-heading h1 { + margin: 0; + font-size: clamp(1.7rem, 3vw, 2.15rem); + font-weight: 800; + letter-spacing: -1px; +} + +.lab-users-heading p { + margin: 9px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .84rem; + line-height: 1.6; +} + +.lab-users-summary { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: 18px; + margin-bottom: 24px; +} + +.lab-users-capacity, +.lab-users-summary-card, +.lab-users-panel, +.lab-users-info { + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 16px; +} + +.lab-users-capacity { + padding: 24px; +} + +.lab-capacity-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + + .lab-capacity-heading > div { + display: flex; + flex-direction: column; + gap: 7px; + } + + .lab-capacity-heading span { + color: var(--mud-palette-text-secondary); + font-size: .77rem; + } + + .lab-capacity-heading strong { + font-size: 1.15rem; + font-weight: 800; + } + +.lab-users-capacity p { + margin: 0; + color: var(--mud-palette-text-secondary); + font-size: .74rem; + line-height: 1.6; +} + +.lab-users-summary-card { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 23px; +} + +.lab-users-summary-icon { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 12px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-users-summary-card > span:nth-child(2) { + margin-top: 15px; + color: var(--mud-palette-text-secondary); + font-size: .76rem; +} + +.lab-users-summary-card strong { + margin-top: 5px; + font-size: 1.45rem; + font-weight: 800; +} + +.lab-users-summary-card small { + margin-top: 5px; + color: var(--mud-palette-text-secondary); + font-size: .72rem; +} + +.lab-users-panel { + overflow: hidden; +} + +.lab-users-panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 25px 25px 18px; +} + + .lab-users-panel-heading h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 750; + } + + .lab-users-panel-heading p { + margin: 6px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .76rem; + line-height: 1.5; + } + +.lab-users-toolbar { + display: flex; + align-items: center; + gap: 14px; + padding: 0 25px 22px; +} + +.lab-users-search { + flex: 1; + min-width: 0; +} + +.lab-users-filter { + width: 180px; + flex-shrink: 0; +} + +.lab-users-panel ::deep .lab-users-table { + box-shadow: none; + background: transparent; +} + +.lab-users-panel ::deep .mud-table-head { + background: var(--mud-palette-background-gray); +} + +.lab-users-panel ::deep .mud-table-cell { + padding: 15px 20px; + border-color: var(--mud-palette-lines-default); +} + +.lab-user-cell { + display: flex; + align-items: center; + gap: 12px; + min-width: 200px; +} + + .lab-user-cell > div:last-child { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .lab-user-cell strong { + font-size: .83rem; + font-weight: 700; + } + + .lab-user-cell span, + .lab-user-cell small { + color: var(--mud-palette-text-secondary); + font-size: .72rem; + overflow-wrap: anywhere; + } + +.lab-user-date { + color: var(--mud-palette-text-secondary); + font-size: .77rem; +} + +.lab-user-actions { + display: flex; + justify-content: flex-end; + gap: 2px; +} + +.lab-users-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + padding: 45px 15px; +} + + .lab-users-empty strong { + font-size: .9rem; + } + + .lab-users-empty p { + margin: 0; + color: var(--mud-palette-text-secondary); + font-size: .77rem; + } + +.lab-users-bottom { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 22px; +} + +.lab-users-info { + display: flex; + align-items: flex-start; + gap: 12px; + max-width: 720px; + padding: 20px; +} + + .lab-users-info strong { + font-size: .83rem; + font-weight: 700; + } + + .lab-users-info p { + margin: 5px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .75rem; + line-height: 1.6; + } + +@media (max-width: 1100px) { + .lab-users-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .lab-users-capacity { + grid-column: 1 / -1; + } +} + +@media (max-width: 760px) { + .lab-users-page { + padding: 28px 0 52px; + } + + .lab-users-heading, + .lab-users-bottom { + flex-direction: column; + align-items: flex-start; + } + + .lab-users-summary { + gap: 12px; + } + + .lab-users-panel-heading { + padding: 20px; + } + + .lab-users-toolbar { + flex-direction: column; + align-items: stretch; + padding: 0 20px 20px; + } + + .lab-users-filter { + width: 100%; + } + + .lab-users-panel ::deep .mud-table-cell { + padding: 12px 16px; + } +} + +@media (max-width: 480px) { + .lab-users-summary { + grid-template-columns: 1fr; + } + + .lab-users-capacity { + grid-column: auto; + } + + .lab-capacity-heading { + flex-wrap: wrap; + } + + .lab-users-summary-card { + padding: 18px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor new file mode 100644 index 00000000..804a657d --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor @@ -0,0 +1,275 @@ +@page "/dashboard" +@layout DashboardLayout + +@* TODO: Enable after authentication integration. +@attribute [UAuthAuthorize] +*@ + +Dashboard · Identity Lab + +
+ + +
+
+ IDENTITY WORKSPACE +

Welcome to your sandbox.

+

+ Explore your identity, manage sessions, and + experience UltimateAuth in action. +

+
+ + + Demo Preview + +
+ +
+
+

My Identity

+

Your personal authentication and security overview.

+
+
+ +
+ +
+ + + + Identity status + Authenticated + Example identity state +
+ +
+ + + + My sessions + 2 + Example active sessions +
+ +
+ + + + My roles + 1 + Example assigned role +
+ +
+ + + + My permissions + View + Explore access decisions +
+ +
+ +
+ +
+
+
+

Identity Details

+

Your account and authentication context.

+
+ +
+ +
+ + + + +
+ Sandbox User + Example account +
+ + + + + Active + +
+ +
+ Username + demo-user +
+ +
+ Authentication + Password +
+ +
+ Session model + Root / Chain / Session +
+ + + Explore My Identity + +
+ +
+
+
+

Session Management

+

Discover how device sessions are managed.

+
+ +
+ +
+ + + +
+ Windows · Chrome + Example current device +
+ + + Current + +
+ +
+ + + +
+ Mobile · Safari + Example remote device +
+ + +
+ + + Manage My Sessions + +
+ +
+ + @if (ShowManagement) + { +
+
+ SANDBOX MANAGEMENT +

Manage your environment

+

+ Create users, configure authorization, and + experiment with identity operations. +

+
+
+ + + +
+
+ Your sandbox, your experiments. +

+ Each sandbox supports up to four users, + including its owner. +

+
+ + + 1 / 4 example users + +
+ } + +
+
+ +@code { + // Temporary design preview. Must be replaced by + // server-validated, sandbox-scoped management permission. + private bool ShowManagement => true; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor.css new file mode 100644 index 00000000..3e95c669 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/DashboardPage.razor.css @@ -0,0 +1,317 @@ +.lab-overview { + padding: 42px 0 80px; +} + +.lab-overview-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 42px; +} + +.lab-overview-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--mud-palette-primary); + font-size: .67rem; + font-weight: 800; + letter-spacing: 1.2px; +} + +.lab-overview-heading h1 { + margin: 0; + color: var(--mud-palette-text-primary); + font-size: clamp(1.65rem, 3vw, 2.15rem); + font-weight: 800; + letter-spacing: -1px; +} + +.lab-overview-heading p, +.lab-overview-section-title p { + margin: 8px 0 0; + color: var(--mud-palette-text-secondary); + font-size: .86rem; + line-height: 1.6; +} + +.lab-overview-section-title { + margin-bottom: 20px; +} + + .lab-overview-section-title h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 750; + letter-spacing: -.4px; + } + +.lab-overview-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 22px; +} + +.lab-stat-card, +.lab-panel, +.lab-management-card { + background: var(--mud-palette-surface); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 16px; +} + +.lab-stat-card { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 22px; +} + +.lab-stat-icon, +.lab-management-icon { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 12px; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); +} + +.lab-stat-label { + margin-top: 19px; + color: var(--mud-palette-text-secondary); + font-size: .76rem; +} + +.lab-stat-card strong { + margin-top: 5px; + color: var(--mud-palette-text-primary); + font-size: 1.45rem; + font-weight: 800; + letter-spacing: -.6px; +} + +.lab-stat-card small { + margin-top: 5px; + color: var(--mud-palette-text-secondary); + font-size: .72rem; +} + +.lab-overview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.lab-panel { + min-width: 0; + padding: 26px; +} + +.lab-panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + + .lab-panel-heading h3 { + margin: 0; + font-size: 1rem; + font-weight: 750; + } + + .lab-panel-heading p { + margin: 5px 0 0; + font-size: .76rem; + line-height: 1.5; + color: var(--mud-palette-text-secondary); + } + +.lab-profile, +.lab-session-item { + display: flex; + align-items: center; + gap: 13px; +} + +.lab-profile { + margin-bottom: 22px; +} + + .lab-profile > div:nth-child(2), + .lab-session-item > div:nth-child(2) { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .lab-profile strong, + .lab-session-item strong { + font-size: .83rem; + font-weight: 700; + } + + .lab-profile span, + .lab-session-item span { + color: var(--mud-palette-text-secondary); + font-size: .72rem; + } + +.lab-detail-row { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 13px 0; + border-top: 1px solid var(--mud-palette-lines-default); + font-size: .79rem; +} + + .lab-detail-row span { + color: var(--mud-palette-text-secondary); + } + + .lab-detail-row strong { + font-weight: 650; + text-align: right; + } + +.lab-session-item { + padding: 18px 0; + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-session-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-background-gray); + color: var(--mud-palette-text-secondary); +} + +.lab-management-heading { + margin-top: 48px; +} + +.lab-management-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.lab-management-card { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 25px; + text-decoration: none; + color: var(--mud-palette-text-primary); + transition: border-color .2s ease, transform .2s ease; +} + + .lab-management-card:hover { + border-color: var(--mud-palette-primary); + transform: translateY(-2px); + } + + .lab-management-card > strong { + margin-top: 20px; + font-size: .95rem; + font-weight: 750; + } + + .lab-management-card p { + flex: 1; + margin: 9px 0 22px; + color: var(--mud-palette-text-secondary); + font-size: .78rem; + line-height: 1.6; + } + +.lab-management-link { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--mud-palette-primary); + font-size: .78rem; + font-weight: 700; +} + +.lab-sandbox-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 22px; + padding: 24px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 16px; + background: var(--mud-palette-primary-hover); +} + + .lab-sandbox-banner strong { + font-size: .9rem; + } + + .lab-sandbox-banner p { + margin: 5px 0 0; + font-size: .78rem; + color: var(--mud-palette-text-secondary); + } + +@media (max-width: 1100px) { + .lab-overview-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .lab-overview { + padding: 28px 0 52px; + } + + .lab-overview-heading { + flex-direction: column; + margin-bottom: 32px; + } + + .lab-overview-grid, + .lab-management-grid { + grid-template-columns: 1fr; + } + + .lab-management-heading { + margin-top: 36px; + } +} + +@media (max-width: 480px) { + .lab-overview-stats { + gap: 10px; + } + + .lab-stat-card { + padding: 16px; + } + + .lab-stat-card strong { + font-size: 1.2rem; + } + + .lab-panel { + padding: 20px; + } + + .lab-sandbox-banner { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Error.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Error.razor new file mode 100644 index 00000000..576cc2d2 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor new file mode 100644 index 00000000..beb937b2 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor @@ -0,0 +1,408 @@ +@page "/" +@layout PublicLayout + +Identity Lab — Explore UltimateAuth + + + + + + + + + + +
+ + + + +
+ + THE ULTIMATEAUTH EXPERIENCE +
+ + + + + + Open Source on GitHub + + + + + + Identity, made + interactive. + + + + Create your sandbox. Manage identities. + Explore authentication, authorization and session management powered by UltimateAuth. + + + + + Create Your Sandbox + + + + Explore Docs + + + + + + Free to explore completely + + Real auth flows + +
+ + + +
+
+
+ + UltimateAuth +
+ IDENTITY LAB · PREVIEW +
+ +
+
+
+
AUTHENTICATION PLATFORM
+ One identity. Complete control. +
+ + Active + +
+ +
+
+ + Unified + Session model +
+
+ + 3 Devices + Managed centrally +
+
+ + Authorized + Roles & permissions +
+
+ +
+
+ Unified Session Architecture + Lifecycle aware +
+
+ Root + + Chain + + Session +
+
+ +
+
+ +
+ Windows · Chrome + Current session +
+ Current +
+ +
+ +
+ iPhone · Safari + Remote session +
+ Revoke +
+ +
+ + Sign out from other devices + +
+
+
+
+
+ +
+
+
+ +
+ + +
+ + BUILT FOR EXPLORATION + + + + Experience identity in action. + + + + Go beyond documentation. Explore real identity workflows + through an interactive sandbox. + + + + Powered by UltimateAuth, not simulated. + Every authentication, authorization, and session management + capability demonstrated here is powered by UltimateAuth itself. + If it works in Identity Lab, it's a capability you can + build with UltimateAuth. + +
+ + + @foreach (var feature in _features) + { + + +
+ +
+ + @feature.Title + + @feature.Description + +
+
+ } +
+ +
+
+ +
+ + +
+ + GET STARTED + + + + Your sandbox, in three steps. + +
+ + + @foreach (var step in _steps) + { + +
+
@step.Number
+ @step.Title + + @step.Description + +
+
+ } +
+ +
+
+ +
+ +
+ + Ready to explore identity? + + + + Create your sandbox and discover what + UltimateAuth can do. + + + + Get Started + +
+
+
+ +
+ + +
+ + A SHARED DEVELOPER SANDBOX + + + + Explore freely. Use responsibly. + + + + Identity Lab is a free, shared environment for + learning, experimentation, and discovering issues + in UltimateAuth. Help us keep it available for everyone. + +
+ + + + + +
+ +
+ + Learn & test + + Explore authentication, authorization, + sessions, and account management to understand + how UltimateAuth works. + +
+
+ + + +
+ +
+ + Fair use + + Avoid automated load testing, excessive + requests, or activities that could disrupt + the shared environment. + +
+
+ + + +
+ +
+ + Test data only + + Use fictional accounts and test credentials. + Never submit real personal, confidential, + or production information. + +
+
+ + + +
+ +
+ + Report issues + + Found unexpected behavior? Help improve + UltimateAuth by sharing your findings + with the open-source community. + +
+
+ +
+ + + +
+
+ +@code { + private readonly (string Title, string Description, string Icon)[] _features = + [ + ("Authentication", + "Experience login, registration, and secure identity flows.", + Icons.Material.Filled.Lock), + + ("Authorization", + "Explore roles, permissions, and access control.", + Icons.Material.Filled.AdminPanelSettings), + + ("Sessions", + "Inspect active sessions and authentication lifecycle.", + Icons.Material.Filled.Devices), + + ("Sandbox", + "Manage identities in your own isolated demo workspace.", + Icons.Material.Filled.Science) + ]; + + private readonly (string Number, string Title, string Description)[] _steps = + [ + ("01", "Create your account", + "Register to get started with Identity Lab."), + + ("02", "Launch your sandbox", + "Access your personal workspace and manage test identities."), + + ("03", "Explore UltimateAuth", + "Experiment with authentication, sessions, and authorization.") + ]; +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor.css new file mode 100644 index 00000000..47a473dc --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LandingPage.razor.css @@ -0,0 +1,451 @@ +.lab-hero { + padding: 112px 0 120px; + background: radial-gradient( ellipse at 80% 30%, color-mix(in srgb, var(--mud-palette-primary) 12%, transparent), transparent 55% ), linear-gradient( 135deg, var(--mud-palette-background) 0%, var(--mud-palette-surface) 100% ); +} + +.lab-eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 1px; + margin-bottom: 24px; +} + +.lab-hero-title { + font-weight: 800; + line-height: 1.12; + letter-spacing: -2px; + margin-bottom: 24px; +} + + .lab-hero-title span { + display: block; + color: var(--mud-palette-primary); + } + +.lab-hero-description { + max-width: 510px; + line-height: 1.8; + color: var(--mud-palette-text-secondary); +} + +.lab-preview { + overflow: hidden; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 20px; + background: var(--mud-palette-surface); + box-shadow: 0 25px 70px rgba(0, 0, 0, 0.09); + transform: rotate(-1deg); +} + +.lab-preview-header { + display: flex; + align-items: center; + gap: 7px; + padding: 15px 20px; + border-bottom: 1px solid var(--mud-palette-lines-default); +} + +.lab-preview-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--mud-palette-lines-default); +} + +.lab-preview-url { + margin-left: 15px; + color: var(--mud-palette-text-secondary); + font-size: 0.75rem; +} + +.lab-preview-content { + padding: 32px; +} + +.lab-preview-label { + font-size: 0.7rem; + letter-spacing: 1.4px; + color: var(--mud-palette-primary); + font-weight: 700; + margin-bottom: 12px; +} + +.lab-preview-stat { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; +} + +.lab-preview-activity { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + margin-top: 20px; + border-radius: 12px; + background: var(--mud-palette-background); +} + +.lab-section { + padding: 100px 0; +} + +.lab-section-alt { + background: var(--mud-palette-background-gray); +} + +.lab-section-heading { + text-align: center; + max-width: 650px; + margin: 0 auto 50px; +} + + .lab-section-heading .mud-typography-h3 { + font-weight: 750; + margin: 12px 0 20px; + } + +.lab-feature-card { + height: 100%; + padding: 28px; + border-radius: 16px; + display: flex; + flex-direction: column; + gap: 16px; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + + .lab-feature-card:hover { + transform: translateY(-4px); + box-shadow: 0 14px 35px rgba(0, 0, 0, 0.06); + } + +.lab-feature-icon { + display: flex; + justify-content: center; + align-items: center; + width: 52px; + height: 52px; + border-radius: 12px; + background: var(--mud-palette-primary-hover); +} + +.lab-step { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.lab-step-number { + font-size: 2rem; + font-weight: 800; + color: var(--mud-palette-primary); +} + +.lab-final-cta { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 24px; + padding: 64px 32px; + border-radius: 24px; + background: var(--mud-palette-background-gray); +} + + .lab-final-cta .mud-typography-h3 { + font-weight: 750; + } + +@media (max-width: 960px) { + .lab-hero { + padding: 72px 0; + } + + .lab-hero-title { + letter-spacing: -1px; + } + + .lab-preview { + transform: none; + margin-top: 32px; + } + + .lab-section { + padding: 64px 0; + } +} + + + +.lab-preview { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 20px; + background: var(--mud-palette-surface); + box-shadow: 0 24px 65px rgba(0, 45, 65, .10); + overflow: hidden; + transform: rotate(-1deg); + max-width: 560px; + margin: auto; +} + +.lab-preview-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 13px 20px; + border-bottom: 1px solid var(--mud-palette-lines-default); +} + +.lab-preview-brand, +.lab-preview-online, +.lab-preview-device, +.lab-preview-signout { + display: flex; + align-items: center; + gap: 9px; +} + +.lab-preview-brand { + font-size: 13px; + font-weight: 800; +} + +.lab-preview-badge, +.lab-preview-eyebrow { + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + color: var(--mud-palette-primary); +} + +.lab-preview-badge { + white-space: nowrap; +} + +.lab-preview-body { + padding: 22px; +} + +.lab-preview-heading, +.lab-preview-row-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lab-preview-eyebrow { + margin-bottom: 6px; +} + +.lab-preview-online { + font-size: 11px; + color: var(--mud-palette-success); +} + + .lab-preview-online span { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + } + +.lab-preview-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 9px; + margin-top: 20px; +} + +.lab-preview-metric { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; + padding: 13px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + min-width: 0; +} + + .lab-preview-metric strong, + .lab-preview-device strong { + font-size: 12px; + line-height: 1.3; + } + + .lab-preview-metric small, + .lab-preview-device small, + .lab-preview-muted { + font-size: 10px; + color: var(--mud-palette-text-secondary); + } + +.lab-preview-flow { + margin-top: 12px; + padding: 14px; + border-radius: 11px; + background: var(--mud-palette-background); +} + +.lab-preview-row-title { + font-size: 11px; + font-weight: 700; + margin-bottom: 12px; +} + +.lab-preview-chain { + display: flex; + align-items: center; + justify-content: space-between; + gap: 5px; + color: var(--mud-palette-primary); +} + + .lab-preview-chain span { + flex: 1; + text-align: center; + padding: 8px 5px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 7px; + background: var(--mud-palette-surface); + color: var(--mud-palette-text-primary); + font-size: 11px; + font-weight: 600; + } + +.lab-preview-bottom { + margin-top: 12px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + overflow: hidden; +} + +.lab-preview-device { + padding: 11px 14px; + border-bottom: 1px solid var(--mud-palette-lines-default); +} + + .lab-preview-device > div { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + } + +.lab-preview-current, +.lab-preview-revoke-label { + font-size: 10px; + font-weight: 700; + color: var(--mud-palette-success); +} + +.lab-preview-revoke-label { + color: var(--mud-palette-error); +} + +.lab-preview-signout { + justify-content: center; + padding: 11px; + font-size: 11px; + font-weight: 700; + color: var(--mud-palette-error); + background: var(--mud-palette-background); +} + +.lab-guidelines-section { + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-guideline-card { + height: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + border-radius: 14px; + background: var(--mud-palette-surface); +} + + .lab-guideline-card .mud-typography-h6 { + font-size: 1rem; + font-weight: 700; + } + + .lab-guideline-card .mud-typography-body2 { + font-size: .82rem; + line-height: 1.65; + } + +.lab-guidelines-footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-top: 24px; +} + +.lab-opensource-link { + display: inline-flex; + align-items: center; + gap: 8px; + width: fit-content; + margin-top: 16px; + margin-bottom: 12px; + padding: 7px 12px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 999px; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + text-decoration: none; + font-size: .76rem; + font-weight: 650; + transition: border-color .2s ease; +} + + .lab-opensource-link:hover { + border-color: var(--mud-palette-primary); + color: var(--mud-palette-primary); + } + +@media (max-width: 599.98px) { + .lab-guidelines-footer { + flex-direction: column; + align-items: center; + text-align: center; + } +} + +@media (max-width: 960px) { + .lab-preview { + transform: none; + } +} + +@media (max-width: 480px) { + .lab-preview-body { + padding: 14px; + } + + .lab-preview-metric { + padding: 9px; + } + + .lab-preview-badge { + font-size: 8px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor new file mode 100644 index 00000000..dcc66f72 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor @@ -0,0 +1,160 @@ +@page "/login" +@using CodeBeam.UltimateAuth.Core.Contracts +@layout PublicLayout + +Sign In · Identity Lab + + + +@code { + private MudForm? _form; + private string _identifier = string.Empty; + private string _password = string.Empty; + private bool _rememberMe; + private bool _showPassword; + + private void TogglePassword() + { + _showPassword = !_showPassword; + } + + private void HandleLoginResult(IUAuthTryResult result) + { + // Sonraki adımda başarısız giriş mesajlarını + // ve hesap kilidi durumunu burada işleyeceğiz. + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor.css new file mode 100644 index 00000000..b2dbfd76 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/LoginPage.razor.css @@ -0,0 +1,293 @@ +.lab-login-page { + padding: 64px 0 80px; + background: var(--mud-palette-background); +} + +.lab-login-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, .9fr); + gap: 72px; + align-items: center; +} + +.lab-login-form-section { + display: flex; + justify-content: center; + min-width: 0; +} + +.lab-login-form-wrap { + width: 100%; + max-width: 440px; +} + +.lab-login-intro { + margin-bottom: 28px; +} + +.lab-login-eyebrow { + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: 14px; + color: var(--mud-palette-primary); + font-size: 10px; + font-weight: 750; + letter-spacing: 1.2px; +} + +.lab-login-intro h1 { + margin: 0; + color: var(--mud-palette-text-primary); + font-size: clamp(1.8rem, 3vw, 2.25rem); + font-weight: 800; + letter-spacing: -1.2px; + line-height: 1.2; +} + +.lab-login-intro p { + margin: 10px 0 0; + color: var(--mud-palette-text-secondary); + font-size: 0.9rem; + line-height: 1.6; +} + +.lab-login-title { + font-weight: 800; + letter-spacing: -1.4px; +} + +.lab-login-subtitle { + margin-top: 10px; + margin-bottom: 28px; +} + +.lab-login-card { + padding: 30px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 18px; + background: var(--mud-palette-surface); + box-shadow: 0 12px 36px rgba(0, 30, 55, .035); +} + +.lab-login-card-content { + padding: 32px; +} + +.lab-login-form-options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lab-login-divider { + position: relative; + display: flex; + align-items: center; + gap: 14px; + margin: 26px 0; + color: var(--mud-palette-text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + white-space: nowrap; +} + + .lab-login-divider::before, + .lab-login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--mud-palette-lines-default); + } + +.lab-login-note { + display: flex; + justify-content: center; + align-items: center; + gap: 7px; + margin-top: 20px; + font-size: 12px; + color: var(--mud-palette-text-secondary); +} + +.lab-login-showcase { + position: relative; + overflow: hidden; + border-radius: 24px; + background: linear-gradient( 145deg, var(--mud-palette-secondary), #087f7b ); + color: #ffffff; +} + + .lab-login-showcase::before { + content: ""; + position: absolute; + width: 350px; + height: 350px; + right: -140px; + top: -140px; + border-radius: 50%; + background: rgba(255, 255, 255, .07); + } + + .lab-login-showcase::after { + content: ""; + position: absolute; + width: 300px; + height: 300px; + left: -150px; + bottom: -150px; + border-radius: 50%; + background: rgba(255, 255, 255, .05); + } + +.lab-login-showcase-inner { + position: relative; + z-index: 1; + padding: 52px 42px; +} + +.lab-login-showcase-label { + margin-bottom: 20px; + color: #a5f1e6; + font-size: 11px; + font-weight: 800; + letter-spacing: 1.5px; +} + +.lab-login-showcase-title { + font-weight: 800; + letter-spacing: -1px; + line-height: 1.2; + color: #ffffff; +} + + .lab-login-showcase-title span { + display: block; + color: #a5f1e6; + } + +.lab-login-showcase-description { + max-width: 380px; + margin-top: 18px; + margin-bottom: 36px; + color: rgba(255, 255, 255, .82); + line-height: 1.7; +} + +.lab-login-feature { + display: flex; + align-items: flex-start; + gap: 15px; + padding: 17px 0; + border-top: 1px solid rgba(255, 255, 255, .18); +} + +.lab-login-feature-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 42px; + height: 42px; + border-radius: 12px; + background: rgba(255, 255, 255, .12); + color: #ffffff; +} + +.lab-login-feature strong { + display: block; + font-size: 14px; + font-weight: 700; +} + +.lab-login-feature p { + margin: 5px 0 0; + font-size: 12px; + line-height: 1.6; + color: rgba(255, 255, 255, .76); +} + +.lab-login-showcase-footer { + display: flex; + align-items: center; + gap: 8px; + margin-top: 28px; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, .18); + font-size: 12px; + color: #a5f1e6; +} + +.lab-login-fields { + display: flex; + flex-direction: column; + gap: 20px; +} + +.lab-login-register { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin-top: 24px; + padding-top: 22px; + border-top: 1px solid var(--mud-palette-lines-default); + font-size: 0.82rem; + color: var(--mud-palette-text-secondary); +} + +.lab-login-note { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-top: 20px; + font-size: 0.75rem; + color: var(--mud-palette-text-secondary); +} + +@media (max-width: 600px) { + .lab-login-intro { + margin-bottom: 22px; + } + + .lab-login-card { + padding: 22px; + } + + .lab-login-fields { + gap: 16px; + } +} + +@media (max-width: 960px) { + .lab-login-grid { + grid-template-columns: 1fr; + gap: 0; + } + + .lab-login-showcase { + display: none; + } + + .lab-login-page { + padding: 48px 0 64px; + } +} + +@media (max-width: 600px) { + .lab-login-page { + padding: 32px 0 48px; + } + + .lab-login-card-content { + padding: 22px; + } + + .lab-login-title { + letter-spacing: -0.8px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor new file mode 100644 index 00000000..c9aa085a --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor @@ -0,0 +1,217 @@ +@page "/register" +@layout PublicLayout + +Create Sandbox · Identity Lab + + + +@code { + private string _username = string.Empty; + private string _email = string.Empty; + private string _password = string.Empty; + private string _confirmPassword = string.Empty; + private bool _showPassword; + private MudForm? _form; + + private static string? ValidateEmail(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "Email address is required."; + + return new System.ComponentModel.DataAnnotations.EmailAddressAttribute() + .IsValid(value) + ? null + : "Enter a valid email address."; + } + + private static string? ValidatePassword(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "Password is required."; + + if (value.Length < 8) + return "Password must be at least 8 characters."; + + return null; + } + + private string? ValidateConfirmPassword(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return "Please confirm your password."; + + if (value != _password) + return "Passwords do not match."; + + return null; + } + + private async Task SubmitAsync() + { + if (_form is null) + return; + + await _form.ValidateAsync(); + + if (!_form.IsValid) + return; + + // UltimateAuth registration + sandbox creation + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor.css new file mode 100644 index 00000000..b2dbfd76 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Pages/RegisterPage.razor.css @@ -0,0 +1,293 @@ +.lab-login-page { + padding: 64px 0 80px; + background: var(--mud-palette-background); +} + +.lab-login-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, .9fr); + gap: 72px; + align-items: center; +} + +.lab-login-form-section { + display: flex; + justify-content: center; + min-width: 0; +} + +.lab-login-form-wrap { + width: 100%; + max-width: 440px; +} + +.lab-login-intro { + margin-bottom: 28px; +} + +.lab-login-eyebrow { + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: 14px; + color: var(--mud-palette-primary); + font-size: 10px; + font-weight: 750; + letter-spacing: 1.2px; +} + +.lab-login-intro h1 { + margin: 0; + color: var(--mud-palette-text-primary); + font-size: clamp(1.8rem, 3vw, 2.25rem); + font-weight: 800; + letter-spacing: -1.2px; + line-height: 1.2; +} + +.lab-login-intro p { + margin: 10px 0 0; + color: var(--mud-palette-text-secondary); + font-size: 0.9rem; + line-height: 1.6; +} + +.lab-login-title { + font-weight: 800; + letter-spacing: -1.4px; +} + +.lab-login-subtitle { + margin-top: 10px; + margin-bottom: 28px; +} + +.lab-login-card { + padding: 30px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 18px; + background: var(--mud-palette-surface); + box-shadow: 0 12px 36px rgba(0, 30, 55, .035); +} + +.lab-login-card-content { + padding: 32px; +} + +.lab-login-form-options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lab-login-divider { + position: relative; + display: flex; + align-items: center; + gap: 14px; + margin: 26px 0; + color: var(--mud-palette-text-secondary); + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + white-space: nowrap; +} + + .lab-login-divider::before, + .lab-login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--mud-palette-lines-default); + } + +.lab-login-note { + display: flex; + justify-content: center; + align-items: center; + gap: 7px; + margin-top: 20px; + font-size: 12px; + color: var(--mud-palette-text-secondary); +} + +.lab-login-showcase { + position: relative; + overflow: hidden; + border-radius: 24px; + background: linear-gradient( 145deg, var(--mud-palette-secondary), #087f7b ); + color: #ffffff; +} + + .lab-login-showcase::before { + content: ""; + position: absolute; + width: 350px; + height: 350px; + right: -140px; + top: -140px; + border-radius: 50%; + background: rgba(255, 255, 255, .07); + } + + .lab-login-showcase::after { + content: ""; + position: absolute; + width: 300px; + height: 300px; + left: -150px; + bottom: -150px; + border-radius: 50%; + background: rgba(255, 255, 255, .05); + } + +.lab-login-showcase-inner { + position: relative; + z-index: 1; + padding: 52px 42px; +} + +.lab-login-showcase-label { + margin-bottom: 20px; + color: #a5f1e6; + font-size: 11px; + font-weight: 800; + letter-spacing: 1.5px; +} + +.lab-login-showcase-title { + font-weight: 800; + letter-spacing: -1px; + line-height: 1.2; + color: #ffffff; +} + + .lab-login-showcase-title span { + display: block; + color: #a5f1e6; + } + +.lab-login-showcase-description { + max-width: 380px; + margin-top: 18px; + margin-bottom: 36px; + color: rgba(255, 255, 255, .82); + line-height: 1.7; +} + +.lab-login-feature { + display: flex; + align-items: flex-start; + gap: 15px; + padding: 17px 0; + border-top: 1px solid rgba(255, 255, 255, .18); +} + +.lab-login-feature-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 42px; + height: 42px; + border-radius: 12px; + background: rgba(255, 255, 255, .12); + color: #ffffff; +} + +.lab-login-feature strong { + display: block; + font-size: 14px; + font-weight: 700; +} + +.lab-login-feature p { + margin: 5px 0 0; + font-size: 12px; + line-height: 1.6; + color: rgba(255, 255, 255, .76); +} + +.lab-login-showcase-footer { + display: flex; + align-items: center; + gap: 8px; + margin-top: 28px; + padding-top: 20px; + border-top: 1px solid rgba(255, 255, 255, .18); + font-size: 12px; + color: #a5f1e6; +} + +.lab-login-fields { + display: flex; + flex-direction: column; + gap: 20px; +} + +.lab-login-register { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin-top: 24px; + padding-top: 22px; + border-top: 1px solid var(--mud-palette-lines-default); + font-size: 0.82rem; + color: var(--mud-palette-text-secondary); +} + +.lab-login-note { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-top: 20px; + font-size: 0.75rem; + color: var(--mud-palette-text-secondary); +} + +@media (max-width: 600px) { + .lab-login-intro { + margin-bottom: 22px; + } + + .lab-login-card { + padding: 22px; + } + + .lab-login-fields { + gap: 16px; + } +} + +@media (max-width: 960px) { + .lab-login-grid { + grid-template-columns: 1fr; + gap: 0; + } + + .lab-login-showcase { + display: none; + } + + .lab-login-page { + padding: 48px 0 64px; + } +} + +@media (max-width: 600px) { + .lab-login-page { + padding: 32px 0 48px; + } + + .lab-login-card-content { + padding: 22px; + } + + .lab-login-title { + letter-spacing: -0.8px; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor new file mode 100644 index 00000000..ec87485a --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/Routes.razor @@ -0,0 +1,103 @@ +@using CodeBeam.UltimateAuth.Client.Blazor +@using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure +@using MudBlazor +@implements IDisposable + +@inject ISnackbar Snackbar +@inject DarkModeManager DarkModeManager + + + + + + + + + + + + + + + + + + + + +@code { + private readonly MudTheme LabTheme = new() + { + PaletteLight = new PaletteLight + { + Primary = "#087f7b", + Secondary = "#11223f", + Background = "#f8fbfc", + Surface = "#ffffff", + TextPrimary = "#08162f", + TextSecondary = "#536581" + }, + + PaletteDark = new PaletteDark + { + Primary = "#29c6b7", + Secondary = "#78a7e3", + + Background = "#091629", + BackgroundGray = "#0d1e33", + Surface = "#10233a", + AppbarBackground = "#0d1e33", + DrawerBackground = "#10233a", + + TextPrimary = "#f1f7ff", + TextSecondary = "#9db1ca", + + LinesDefault = "#294158", + LinesInputs = "#36506a", + + Success = "#35d39c", + Info = "#69baff", + Warning = "#ffbf69", + Error = "#ff7585", + + ActionDefault = "#a9bfd5", + ActionDisabled = "#61758c" + }, + + Typography = new Typography { Default = new DefaultTypography { FontFamily = ["Inter", "sans-serif"] } }, + LayoutProperties = new LayoutProperties { DefaultBorderRadius = "12px" } + }; + + private Task HandleReauth() + { + Snackbar.Add( + "Your session has expired. Please sign in again.", + Severity.Warning); + + return Task.CompletedTask; + } + + protected override void OnInitialized() + { + DarkModeManager.Changed += OnThemeChanged; + } + + private void OnThemeChanged() + { + _ = InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await DarkModeManager.InitializeAsync(); + StateHasChanged(); + } + } + + public void Dispose() + { + DarkModeManager.Changed -= OnThemeChanged; + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor new file mode 100644 index 00000000..233c5407 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Components/_Imports.razor @@ -0,0 +1,25 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using CodeBeam.UltimateAuth.IdentityLab +@using CodeBeam.UltimateAuth.IdentityLab.Components +@using CodeBeam.UltimateAuth.IdentityLab.Components.Layout +@using CodeBeam.UltimateAuth.IdentityLab.Components.Custom + +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Blazor + +@using CodeBeam.UltimateAuth.Sample.Shared.Common +@using CodeBeam.UltimateAuth.Sample.Shared.Components +@using CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs +@using CodeBeam.UltimateAuth.Sample.Shared.Components.Page +@using CodeBeam.UltimateAuth.Sample.Shared.Components.Custom + +@using MudBlazor +@using MudExtensions diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261021071142_InitialUltimateAuth.Designer.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261021071142_InitialUltimateAuth.Designer.cs new file mode 100644 index 00000000..c40fccc1 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261021071142_InitialUltimateAuth.Designer.cs @@ -0,0 +1,734 @@ +// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20261021071142_InitialUltimateAuth")] + partial class InitialUltimateAuth + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialType") + .HasColumnType("integer"); + + b.Property("FailedAttempts") + .HasColumnType("integer"); + + b.Property("LastFailedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("RequiresReauthentication") + .HasColumnType("boolean"); + + b.Property("ResetAttempts") + .HasColumnType("integer"); + + b.Property("ResetConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "LockedUntil"); + + b.HasIndex("Tenant", "ResetRequestedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "Scope"); + + b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") + .IsUnique(); + + b.ToTable("UAuth_Authentication", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RolePermissionProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Tenant", "RoleId", "Permission"); + + b.HasIndex("Tenant", "Permission"); + + b.HasIndex("Tenant", "RoleId"); + + b.ToTable("UAuth_RolePermissions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RoleProjection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "NormalizedName") + .IsUnique(); + + b.ToTable("UAuth_Roles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.UserRoleProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Tenant", "UserKey", "RoleId"); + + b.HasIndex("Tenant", "RoleId"); + + b.HasIndex("Tenant", "UserKey"); + + b.ToTable("UAuth_UserRoles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordCredentialProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeletedAt"); + + b.ToTable("UAuth_PasswordCredentials", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActiveSessionId") + .HasColumnType("text"); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("RotationCount") + .HasColumnType("integer"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TouchCount") + .HasColumnType("integer"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId") + .IsUnique(); + + b.HasIndex("Tenant", "RootId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeviceId"); + + b.ToTable("UAuth_SessionChains", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "SessionId") + .IsUnique(); + + b.HasIndex("Tenant", "ChainId", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_Sessions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("uuid"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenId") + .HasColumnType("uuid"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "ReplacedByTokenHash"); + + b.HasIndex("Tenant", "SessionId"); + + b.HasIndex("Tenant", "TokenHash") + .IsUnique(); + + b.HasIndex("Tenant", "TokenId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "ExpiresAt", "RevokedAt"); + + b.HasIndex("Tenant", "TokenHash", "RevokedAt"); + + b.ToTable("UAuth_RefreshTokens", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserIdentifierProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "NormalizedValue"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "Type", "NormalizedValue") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey", "IsPrimary"); + + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("Gender") + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); + + b.ToTable("UAuth_UserProfiles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", null) + .WithMany() + .HasForeignKey("Tenant", "RootId") + .HasPrincipalKey("Tenant", "RootId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", null) + .WithMany() + .HasForeignKey("Tenant", "ChainId") + .HasPrincipalKey("Tenant", "ChainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261021071142_InitialUltimateAuth.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261021071142_InitialUltimateAuth.cs new file mode 100644 index 00000000..423fe4c1 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/20261021071142_InitialUltimateAuth.cs @@ -0,0 +1,573 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + /// + public partial class InitialUltimateAuth : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UAuth_Authentication", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Scope = table.Column(type: "integer", nullable: false), + CredentialType = table.Column(type: "integer", nullable: true), + FailedAttempts = table.Column(type: "integer", nullable: false), + LastFailedAt = table.Column(type: "timestamp with time zone", nullable: true), + LockedUntil = table.Column(type: "timestamp with time zone", nullable: true), + RequiresReauthentication = table.Column(type: "boolean", nullable: false), + ResetRequestedAt = table.Column(type: "timestamp with time zone", nullable: true), + ResetExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + ResetConsumedAt = table.Column(type: "timestamp with time zone", nullable: true), + ResetTokenHash = table.Column(type: "character varying(512)", maxLength: 512, nullable: true), + ResetAttempts = table.Column(type: "integer", nullable: false), + SecurityVersion = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_Authentication", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_PasswordCredentials", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SecretHash = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + SecurityStamp = table.Column(type: "uuid", nullable: false), + LastUsedAt = table.Column(type: "timestamp with time zone", nullable: true), + Source = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_PasswordCredentials", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_RefreshTokens", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TokenId = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + SessionId = table.Column(type: "text", nullable: false), + ChainId = table.Column(type: "uuid", nullable: true), + ReplacedByTokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_RefreshTokens", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_RolePermissions", + columns: table => new + { + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + Permission = table.Column(type: "character varying(256)", maxLength: 256, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_RolePermissions", x => new { x.Tenant, x.RoleId, x.Permission }); + }); + + migrationBuilder.CreateTable( + name: "UAuth_Roles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + NormalizedName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_SessionRoots", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RootId = table.Column(type: "uuid", maxLength: 128, nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + SecurityVersion = table.Column(type: "bigint", nullable: false), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_SessionRoots", x => x.Id); + table.UniqueConstraint("AK_UAuth_SessionRoots_Tenant_RootId", x => new { x.Tenant, x.RootId }); + }); + + migrationBuilder.CreateTable( + name: "UAuth_UserIdentifiers", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Type = table.Column(type: "integer", nullable: false), + Value = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + NormalizedValue = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + IsPrimary = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + VerifiedAt = table.Column(type: "timestamp with time zone", nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_UserIdentifiers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_UserLifecycles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Status = table.Column(type: "integer", nullable: false), + SecurityVersion = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_UserLifecycles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_UserProfiles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ProfileKey = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + FirstName = table.Column(type: "text", nullable: true), + LastName = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + BirthDate = table.Column(type: "date", nullable: true), + Gender = table.Column(type: "text", nullable: true), + Bio = table.Column(type: "text", nullable: true), + Language = table.Column(type: "text", nullable: true), + TimeZone = table.Column(type: "text", nullable: true), + Culture = table.Column(type: "text", nullable: true), + Metadata = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_UserProfiles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_UserRoles", + columns: table => new + { + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + AssignedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_UserRoles", x => new { x.Tenant, x.UserKey, x.RoleId }); + }); + + migrationBuilder.CreateTable( + name: "UAuth_SessionChains", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ChainId = table.Column(type: "uuid", nullable: false), + RootId = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastSeenAt = table.Column(type: "timestamp with time zone", nullable: false), + AbsoluteExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + DeviceId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Device = table.Column(type: "text", nullable: false), + ClaimsSnapshot = table.Column(type: "text", nullable: false), + ActiveSessionId = table.Column(type: "text", nullable: true), + RotationCount = table.Column(type: "integer", nullable: false), + TouchCount = table.Column(type: "integer", nullable: false), + SecurityVersionAtCreation = table.Column(type: "bigint", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_SessionChains", x => x.Id); + table.UniqueConstraint("AK_UAuth_SessionChains_Tenant_ChainId", x => new { x.Tenant, x.ChainId }); + table.ForeignKey( + name: "FK_UAuth_SessionChains_UAuth_SessionRoots_Tenant_RootId", + columns: x => new { x.Tenant, x.RootId }, + principalTable: "UAuth_SessionRoots", + principalColumns: new[] { "Tenant", "RootId" }, + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "UAuth_Sessions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SessionId = table.Column(type: "text", nullable: false), + ChainId = table.Column(type: "uuid", nullable: false), + Tenant = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + UserKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + SecurityVersionAtCreation = table.Column(type: "bigint", nullable: false), + Device = table.Column(type: "text", nullable: false), + Claims = table.Column(type: "text", nullable: false), + Metadata = table.Column(type: "text", nullable: false), + Version = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_Sessions", x => x.Id); + table.ForeignKey( + name: "FK_UAuth_Sessions_UAuth_SessionChains_Tenant_ChainId", + columns: x => new { x.Tenant, x.ChainId }, + principalTable: "UAuth_SessionChains", + principalColumns: new[] { "Tenant", "ChainId" }, + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_LockedUntil", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "LockedUntil" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_ResetRequestedAt", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "ResetRequestedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey_Scope", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "UserKey", "Scope" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey_Scope_CredentialType", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "UserKey", "Scope", "CredentialType" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_ExpiresAt", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "ExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_Id", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "Id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_RevokedAt", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_UserKey", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_UserKey_DeletedAt", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "UserKey", "DeletedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_ChainId", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "ChainId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_ExpiresAt", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "ExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_ExpiresAt_RevokedAt", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "ExpiresAt", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_ReplacedByTokenHash", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "ReplacedByTokenHash" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_SessionId", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "SessionId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_TokenHash", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "TokenHash" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_TokenHash_RevokedAt", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "TokenHash", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_TokenId", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "TokenId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_UserKey", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RolePermissions_Tenant_Permission", + table: "UAuth_RolePermissions", + columns: new[] { "Tenant", "Permission" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RolePermissions_Tenant_RoleId", + table: "UAuth_RolePermissions", + columns: new[] { "Tenant", "RoleId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Roles_Tenant_Id", + table: "UAuth_Roles", + columns: new[] { "Tenant", "Id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Roles_Tenant_NormalizedName", + table: "UAuth_Roles", + columns: new[] { "Tenant", "NormalizedName" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionChains_Tenant_ChainId", + table: "UAuth_SessionChains", + columns: new[] { "Tenant", "ChainId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionChains_Tenant_RootId", + table: "UAuth_SessionChains", + columns: new[] { "Tenant", "RootId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionChains_Tenant_UserKey", + table: "UAuth_SessionChains", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionChains_Tenant_UserKey_DeviceId", + table: "UAuth_SessionChains", + columns: new[] { "Tenant", "UserKey", "DeviceId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionRoots_Tenant_RootId", + table: "UAuth_SessionRoots", + columns: new[] { "Tenant", "RootId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionRoots_Tenant_UserKey", + table: "UAuth_SessionRoots", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionRoots_Tenant_UserKey_RevokedAt", + table: "UAuth_SessionRoots", + columns: new[] { "Tenant", "UserKey", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_ChainId", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "ChainId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_ChainId_RevokedAt", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "ChainId", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_ExpiresAt", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "ExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_RevokedAt", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_SessionId", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "SessionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_UserKey_RevokedAt", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "UserKey", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_NormalizedValue", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "NormalizedValue" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_Type_NormalizedValue", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "Type", "NormalizedValue" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_UserKey", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_IsPrimary", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "UserKey", "IsPrimary" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary_Deleted~", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserLifecycles_Tenant_DeletedAt_CreatedAt_Id", + table: "UAuth_UserLifecycles", + columns: new[] { "Tenant", "DeletedAt", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserLifecycles_Tenant_Status_DeletedAt_CreatedAt_Id", + table: "UAuth_UserLifecycles", + columns: new[] { "Tenant", "Status", "DeletedAt", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserLifecycles_Tenant_UserKey", + table: "UAuth_UserLifecycles", + columns: new[] { "Tenant", "UserKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey_ProfileKey", + table: "UAuth_UserProfiles", + columns: new[] { "Tenant", "UserKey", "ProfileKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserRoles_Tenant_RoleId", + table: "UAuth_UserRoles", + columns: new[] { "Tenant", "RoleId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserRoles_Tenant_UserKey", + table: "UAuth_UserRoles", + columns: new[] { "Tenant", "UserKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UAuth_Authentication"); + + migrationBuilder.DropTable( + name: "UAuth_PasswordCredentials"); + + migrationBuilder.DropTable( + name: "UAuth_RefreshTokens"); + + migrationBuilder.DropTable( + name: "UAuth_RolePermissions"); + + migrationBuilder.DropTable( + name: "UAuth_Roles"); + + migrationBuilder.DropTable( + name: "UAuth_Sessions"); + + migrationBuilder.DropTable( + name: "UAuth_UserIdentifiers"); + + migrationBuilder.DropTable( + name: "UAuth_UserLifecycles"); + + migrationBuilder.DropTable( + name: "UAuth_UserProfiles"); + + migrationBuilder.DropTable( + name: "UAuth_UserRoles"); + + migrationBuilder.DropTable( + name: "UAuth_SessionChains"); + + migrationBuilder.DropTable( + name: "UAuth_SessionRoots"); + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs new file mode 100644 index 00000000..7b6b4d47 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Migrations/UAuthDbContextModelSnapshot.cs @@ -0,0 +1,731 @@ +// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CodeBeam.UltimateAuth.IdentityLab.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + partial class UAuthDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialType") + .HasColumnType("integer"); + + b.Property("FailedAttempts") + .HasColumnType("integer"); + + b.Property("LastFailedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("RequiresReauthentication") + .HasColumnType("boolean"); + + b.Property("ResetAttempts") + .HasColumnType("integer"); + + b.Property("ResetConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "LockedUntil"); + + b.HasIndex("Tenant", "ResetRequestedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "Scope"); + + b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") + .IsUnique(); + + b.ToTable("UAuth_Authentication", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RolePermissionProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Tenant", "RoleId", "Permission"); + + b.HasIndex("Tenant", "Permission"); + + b.HasIndex("Tenant", "RoleId"); + + b.ToTable("UAuth_RolePermissions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RoleProjection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "NormalizedName") + .IsUnique(); + + b.ToTable("UAuth_Roles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.UserRoleProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Tenant", "UserKey", "RoleId"); + + b.HasIndex("Tenant", "RoleId"); + + b.HasIndex("Tenant", "UserKey"); + + b.ToTable("UAuth_UserRoles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordCredentialProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeletedAt"); + + b.ToTable("UAuth_PasswordCredentials", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActiveSessionId") + .HasColumnType("text"); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("RotationCount") + .HasColumnType("integer"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TouchCount") + .HasColumnType("integer"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId") + .IsUnique(); + + b.HasIndex("Tenant", "RootId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeviceId"); + + b.ToTable("UAuth_SessionChains", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Device") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("bigint"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "SessionId") + .IsUnique(); + + b.HasIndex("Tenant", "ChainId", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_Sessions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("uuid"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChainId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TokenId") + .HasColumnType("uuid"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "ReplacedByTokenHash"); + + b.HasIndex("Tenant", "SessionId"); + + b.HasIndex("Tenant", "TokenHash") + .IsUnique(); + + b.HasIndex("Tenant", "TokenId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "ExpiresAt", "RevokedAt"); + + b.HasIndex("Tenant", "TokenHash", "RevokedAt"); + + b.ToTable("UAuth_RefreshTokens", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserIdentifierProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "NormalizedValue"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "Type", "NormalizedValue") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey", "IsPrimary"); + + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityVersion") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id"); + + b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id"); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .HasColumnType("text"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("Gender") + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("ProfileKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TimeZone") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey", "ProfileKey") + .IsUnique(); + + b.ToTable("UAuth_UserProfiles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", null) + .WithMany() + .HasForeignKey("Tenant", "RootId") + .HasPrincipalKey("Tenant", "RootId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", null) + .WithMany() + .HasForeignKey("Tenant", "ChainId") + .HasPrincipalKey("Tenant", "ChainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs new file mode 100644 index 00000000..6ca3bbd2 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Program.cs @@ -0,0 +1,76 @@ +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.IdentityLab.Components; +using CodeBeam.UltimateAuth.Sample.Seed.Extensions; +using CodeBeam.UltimateAuth.Sample.Shared.Infrastructure; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.EntityFrameworkCore; +using MudBlazor.Services; +using MudExtensions.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + +builder.Services.AddMudServices(o => { + o.SnackbarConfiguration.PreventDuplicates = false; +}); +builder.Services.AddMudExtensions(); +builder.Services.AddScoped(); + +var connectionString = builder.Configuration.GetConnectionString("IdentityLab") + ?? throw new InvalidOperationException("Connection string 'IdentityLab' is missing."); + +builder.Services.AddUltimateAuthServer(o => +{ + o.Diagnostics.EnableRefreshDetails = true; + //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); + //o.Session.Lifetime = TimeSpan.FromSeconds(32); + //o.Session.TouchInterval = TimeSpan.FromSeconds(9); + //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); + //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); + //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); + o.Login.MaxFailedAttempts = 5; + o.Login.LockoutDuration = TimeSpan.FromMinutes(1); + o.Identifiers.Behavior.AllowMultipleUsernames = true; +}) + .AddUltimateAuthEntityFrameworkCore(db => + { + db.UseNpgsql(connectionString, + options => options.MigrationsAssembly(typeof(Program).Assembly.GetName().Name)); + }); + +builder.Services.AddUltimateAuthClientBlazor(o => +{ + //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); + o.Reauth.Behavior = ReauthBehavior.RaiseEvent; +}); + +builder.Services.AddScopedUltimateAuthSampleSeed(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); + +app.UseUltimateAuthWithAspNetCore(); +app.UseAntiforgery(); + +app.MapUltimateAuthEndpoints(); +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); + +app.Run(); diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Properties/launchSettings.json b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Properties/launchSettings.json new file mode 100644 index 00000000..2eee1b36 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7154;http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.Development.json b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.json b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/app.css b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/app.css new file mode 100644 index 00000000..73a69d6f --- /dev/null +++ b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/app.css @@ -0,0 +1,60 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/favicon.png b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/favicon.png new file mode 100644 index 00000000..8422b596 Binary files /dev/null and b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/favicon.png differ diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/identity-lab.png b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/identity-lab.png new file mode 100644 index 00000000..06f1229c Binary files /dev/null and b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/identity-lab.png differ diff --git a/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/ultimateauth-logo.png b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/ultimateauth-logo.png new file mode 100644 index 00000000..da686529 Binary files /dev/null and b/apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/wwwroot/ultimateauth-logo.png differ diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Common/UAuthDialog.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Common/UAuthDialog.cs index 8e16bbe6..0e32f7f6 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Common/UAuthDialog.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Common/UAuthDialog.cs @@ -17,13 +17,20 @@ public static DialogParameters GetDialogParameters(UAuthState state, UserKey? us return parameters; } - public static DialogOptions GetDialogOptions(MaxWidth maxWidth = MaxWidth.Medium) + public static DialogOptions GetDialogOptions( + MaxWidth maxWidth = MaxWidth.Medium, + bool allowBackdropClose = false, + bool allowEscapeClose = false, + bool showCloseButton = true) { return new DialogOptions { MaxWidth = maxWidth, FullWidth = true, - CloseButton = true + BackdropClick = allowBackdropClose, + CloseOnEscapeKey = allowEscapeClose, + CloseButton = showCloseButton, + BackgroundClass = "uauth-dialog-backdrop", }; } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor index 0c91e45c..0a370265 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor @@ -1,23 +1,139 @@ -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Users.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService - + diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.cs index c7fc1d63..e70fabed 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.cs @@ -11,67 +11,154 @@ public partial class AccountStatusDialog private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } + + [Parameter] + public bool PreviewMode { get; set; } + + private bool _isSubmitting; + + private string AccountName => + PreviewMode + ? "Alex Morgan" + : AuthState?.Identity?.DisplayName ?? "Your Account"; private async Task SuspendAccountAsync() { - var info = await DialogService.ShowMessageBoxAsync( - title: "Are You Sure", - markupMessage: (MarkupString) - """ - You are going to suspend your account.

- You can still active your account later. - """, - yesText: "Suspend", noText: "Cancel", - options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); - - if (info != true) - { - Snackbar.Add("Suspend process cancelled.", Severity.Info); + if (PreviewMode || _isSubmitting) + return; + + var confirmed = await DialogService.ShowMessageBoxAsync( + title: "Suspend Account", + message: + "Are you sure you want to suspend your account? " + + "You may lose access until your account is reactivated.", + yesText: "Suspend Account", + noText: "Cancel", + options: new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + BackgroundClass = "uauth-blur-slight" + }); + + if (confirmed != true || _isSubmitting) return; - } - ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.SelfSuspended }; - var result = await UAuthClient.Users.ChangeMyStatusAsync(request); - if (result.IsSuccess) + _isSubmitting = true; + + try { - Snackbar.Add("Your account suspended successfully.", Severity.Success); - MudDialog.Close(); + var request = new ChangeUserStatusSelfRequest + { + NewStatus = SelfAssignableUserStatus.SelfSuspended + }; + + var result = await UAuthClient.Users + .ChangeMyStatusAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to suspend account.", + Severity.Error); + return; + } + + Snackbar.Add( + "Your account has been suspended.", + Severity.Success); + + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception) + { + Snackbar.Add( + "An unexpected error occurred while suspending your account.", + Severity.Error); } - else + finally { - Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); + _isSubmitting = false; } } private async Task DeleteAccountAsync() { - var info = await DialogService.ShowMessageBoxAsync( - title: "Are You Sure", - markupMessage: (MarkupString) - """ - You are going to delete your account.

- This action can't be undone.

- (Actually it is, admin can handle soft deleted accounts.) - """, - yesText: "Delete", noText: "Cancel", - options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); - - if (info != true) - { - Snackbar.Add("Deletion cancelled.", Severity.Info); + if (PreviewMode || _isSubmitting) return; - } - var result = await UAuthClient.Users.DeleteMeAsync(); - if (result.IsSuccess) + var confirmed = await DialogService.ShowMessageBoxAsync( + title: "Delete Account", + message: + "Are you sure you want to delete your account? " + + "You will lose access to this identity. " + + "Recovery may not be available.", + yesText: "Continue", + noText: "Cancel", + options: new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + BackgroundClass = "uauth-blur-slight" + }); + + if (confirmed != true || _isSubmitting) + return; + + // Require an additional confirmation for account deletion. + var finalConfirmation = await DialogService.ShowMessageBoxAsync( + title: "Confirm Account Deletion", + message: + "This action will delete your account. " + + "Do you want to proceed?", + yesText: "Delete My Account", + noText: "Cancel", + options: new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + BackgroundClass = "uauth-blur-slight" + }); + + if (finalConfirmation != true || _isSubmitting) + return; + + _isSubmitting = true; + + try { - Snackbar.Add("Your account deleted successfully.", Severity.Success); - MudDialog.Close(); + var result = await UAuthClient.Users.DeleteMeAsync(); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to delete account.", + Severity.Error); + return; + } + + Snackbar.Add( + "Your account has been deleted.", + Severity.Success); + + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception) + { + Snackbar.Add( + "An unexpected error occurred while deleting your account.", + Severity.Error); } - else + finally { - Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); + _isSubmitting = false; } } + + private void Close() + { + if (!_isSubmitting) + MudDialog.Close(); + } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.css new file mode 100644 index 00000000..d818ed58 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/AccountStatusDialog.razor.css @@ -0,0 +1,197 @@ +.lab-account-status-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-account-status-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-account-status-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-account-status-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-account-status-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-account-status-content { + display: flex; + flex-direction: column; + gap: 19px; + min-width: 0; +} + +.lab-account-status-identity { + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + background: var(--mud-palette-background); +} + +.lab-account-status-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 43px; + height: 43px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-account-status-identity-info { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 0; +} + + .lab-account-status-identity-info strong { + font-size: .86rem; + font-weight: 750; + } + + .lab-account-status-identity-info span { + font-size: .73rem; + color: var(--mud-palette-text-secondary); + } + +.lab-account-status-section-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + + .lab-account-status-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-account-status-section-heading span { + font-size: .73rem; + line-height: 1.5; + color: var(--mud-palette-text-secondary); + } + +.lab-account-status-card, +.lab-account-status-danger { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 15px; + padding: 16px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; +} + +.lab-account-status-card-main { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.lab-account-status-action-icon, +.lab-account-status-danger-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 10px; +} + +.lab-account-status-action-icon { + background: var(--mud-palette-warning-hover); + color: var(--mud-palette-warning); +} + +.lab-account-status-danger { + border-color: var(--mud-palette-error); +} + +.lab-account-status-danger-icon { + background: var(--mud-palette-error-hover); + color: var(--mud-palette-error); +} + +.lab-account-status-card-info { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + + .lab-account-status-card-info strong { + font-size: .84rem; + font-weight: 750; + } + + .lab-account-status-card-info span { + font-size: .74rem; + line-height: 1.6; + color: var(--mud-palette-text-secondary); + } + +.lab-account-status-hint { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-account-status-hint span { + font-size: .72rem; + line-height: 1.6; + } + +.lab-account-status-actions { + display: flex; + justify-content: flex-end; + width: 100%; +} + +@media (max-width: 600px) { + .lab-account-status-card, + .lab-account-status-danger { + padding: 13px; + } + + .lab-account-status-identity { + flex-wrap: wrap; + } + + .lab-account-status-card ::deep .mud-button-root, + .lab-account-status-danger ::deep .mud-button-root { + width: 100%; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor index 20127849..076a318a 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor @@ -1,25 +1,148 @@ @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar - + - Create User +
+
+ +
+ +
+ Create User + + Add a new identity to your environment. + +
+
- - - - - - - +
+ + @if (PreviewMode) + { + + This dialog is running in preview mode. + You can explore the form, but user creation is not enabled. + + } + +
+
+ +
+ Identity Information + Basic details used to identify this user. +
+
+ +
+ + + + + +
+
+ + + +
+
+ +
+ Initial Credential + Set the password for the new account. +
+
+ +
+ + + +
+ +
+ + + Password requirements are enforced by the + configured UltimateAuth credential policy. + +
+
+ +
- Cancel - Create + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.cs index 56b88169..683e4c40 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.cs @@ -1,23 +1,47 @@ -using CodeBeam.UltimateAuth.Users.Contracts; + +using CodeBeam.UltimateAuth.Users.Contracts; using Microsoft.AspNetCore.Components; using MudBlazor; +using System.Net.Mail; namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; public partial class CreateUserDialog { private MudForm _form = null!; + private string? _username; private string? _email; private string? _password; private string? _passwordCheck; private string? _displayName; + private bool _isSubmitting; + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; + /// + /// Enables form preview without submitting a user creation request. + /// + [Parameter] + public bool PreviewMode { get; set; } + + /// + /// Optional custom user creation handler. + /// When supplied, the default UltimateAuth admin operation is bypassed. + /// The caller must enforce authorization and application-specific rules. + /// + [Parameter] + public Func>? + OnCreateUserAsync + { get; set; } + private async Task CreateUserAsync() { + if (_isSubmitting || PreviewMode) + return; + await _form.ValidateAsync(); if (!_form.IsValid) @@ -31,25 +55,88 @@ private async Task CreateUserAsync() var request = new CreateUserRequest { - UserName = _username, - Email = _email, - DisplayName = _displayName, + UserName = _username?.Trim(), + Email = _email?.Trim(), + DisplayName = _displayName?.Trim(), Password = _password }; - var result = await UAuthClient.Users.CreateAsAdminAsync(request); + _isSubmitting = true; - if (!result.IsSuccess) + try { - Snackbar.Add(result.ErrorText ?? "User creation failed.", Severity.Error); - return; + CreateUserDialogResult outcome; + + if (OnCreateUserAsync is not null) + { + outcome = await OnCreateUserAsync(request); + } + else + { + var result = await UAuthClient.Users.CreateAsAdminAsync(request); + + outcome = result.IsSuccess + ? CreateUserDialogResult.Success() + : CreateUserDialogResult.Failure( + result.ErrorText ?? "User creation failed."); + } + + if (!outcome.IsSuccess) + { + Snackbar.Add( + outcome.ErrorMessage ?? "User creation failed.", + Severity.Error); + + return; + } + + Snackbar.Add("User created successfully.", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception) + { + Snackbar.Add( + "An unexpected error occurred while creating the user.", + Severity.Error); } + finally + { + _isSubmitting = false; + } + } - Snackbar.Add("User created successfully", Severity.Success); - MudDialog.Close(DialogResult.Ok(true)); + private string? PasswordMatch(string? value) + { + return _password == value + ? null + : "Passwords don't match."; } - private string PasswordMatch(string? arg) => _password != arg ? "Passwords don't match." : string.Empty; + private static string? ValidateEmail(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "Email is required."; + + try + { + var address = new MailAddress(value); + + return string.Equals( + address.Address, + value.Trim(), + StringComparison.OrdinalIgnoreCase) + ? null + : "Enter a valid email address."; + } + catch (FormatException) + { + return "Enter a valid email address."; + } + } - private void Cancel() => MudDialog.Cancel(); + private void Cancel() + { + if (!_isSubmitting) + MudDialog.Cancel(); + } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.css new file mode 100644 index 00000000..cc1e6810 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CreateUserDialog.razor.css @@ -0,0 +1,118 @@ +.lab-create-user-header { + display: flex; + align-items: center; + gap: 15px; + padding: 4px 0; +} + +.lab-create-user-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 14px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-create-user-header-text { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + + .lab-create-user-header-text ::deep .mud-typography-h6 { + font-size: 1.1rem; + font-weight: 800; + letter-spacing: -.4px; + } + + .lab-create-user-header-text ::deep .mud-typography-body2 { + font-size: .77rem; + line-height: 1.5; + } + +.lab-create-user-content { + display: flex; + flex-direction: column; + gap: 23px; + padding: 8px 0; +} + +.lab-create-user-section { + display: flex; + flex-direction: column; + gap: 20px; +} + +.lab-create-user-section-heading { + display: flex; + align-items: flex-start; + gap: 12px; +} + + .lab-create-user-section-heading > div { + display: flex; + flex-direction: column; + gap: 5px; + } + + .lab-create-user-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-create-user-section-heading span { + color: var(--mud-palette-text-secondary); + font-size: .73rem; + line-height: 1.6; + } + +.lab-create-user-fields { + display: grid; + grid-template-columns: 1fr; + gap: 15px; +} + +.lab-create-user-hint { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 13px 15px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-create-user-hint span { + font-size: .72rem; + line-height: 1.65; + } + +.lab-create-user-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + width: 100%; + padding: 5px 0; +} + +@media (max-width: 480px) { + .lab-create-user-header-icon { + width: 42px; + height: 42px; + } + + .lab-create-user-actions { + flex-wrap: wrap; + } + + .lab-create-user-actions ::deep .mud-button-root { + flex: 1; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor index 660b7c3a..2e7076e8 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor @@ -1,51 +1,145 @@ -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Credentials.Contracts -@using CodeBeam.UltimateAuth.Users.Contracts -@inject IUAuthClient UAuthClient +@inject IUAuthClient UAuthClient @inject ISnackbar Snackbar -@inject IDialogService DialogService -@inject IUAuthStateManager StateManager -@inject NavigationManager Nav - + - Credential Management - User: @AuthState?.Identity?.DisplayName +
+
+ +
+ +
+ + @(UserKey is null ? "Change Password" : "Set User Password") + + + @(UserKey is null + ? "Update your account password securely." + : "Assign a new password to the selected identity.") + +
+
+ - - - @if (UserKey == null) + +
+ + @if (PreviewMode) { - - - + + Preview mode is active. Password changes are disabled. + } - else + + @if (UserKey is not null) { - - - Administrators can directly assign passwords to users. - However, using the credential reset flow is generally recommended for better security and auditability. - - + + You are assigning a password to another user. + For better security and auditability, the credential + reset flow is generally recommended. + } - - - +
+
+ +
+ + @(UserKey is null ? "Password Update" : "New Credential") + + + @(UserKey is null + ? "Enter your current password and choose a new one." + : "Choose the password to assign to this identity.") + +
+
+ +
+ @if (UserKey is null) + { + + } - - - + - - @(UserKey is null ? "Change Password" : "Set Password") - - + +
+
+ +
+ + + Password requirements and credential reuse restrictions + are enforced by your UltimateAuth server configuration. + +
+ +
+ +
+ Security + + Updating a password may affect active sessions + according to the configured session revocation policy. + +
+
+ +
+ - Cancel +
+ + Cancel + + + + @(UserKey is null ? "Change Password" : "Set Password") + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor.cs index d860c4b4..da7ad7b7 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor.cs @@ -10,83 +10,99 @@ namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; public partial class CredentialDialog { private MudForm _form = null!; + private string? _oldPassword; private string? _newPassword; private string? _newPasswordCheck; - private bool _passwordMode1 = false; - private bool _passwordMode2 = false; - private bool _passwordMode3 = true; + + private bool _isSubmitting; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } [Parameter] public UserKey? UserKey { get; set; } + [Parameter] + public bool PreviewMode { get; set; } + private async Task ChangePasswordAsync() { - if (_form is null) + if (PreviewMode || _isSubmitting || _form is null) return; await _form.ValidateAsync(); + if (!_form.IsValid) - { - Snackbar.Add("Form is not valid.", Severity.Error); return; - } if (_newPassword != _newPasswordCheck) { - Snackbar.Add("New password and check do not match", Severity.Error); + Snackbar.Add("Passwords don't match.", Severity.Error); return; } - ChangeCredentialRequest request; - - if (UserKey is null) - { - request = new ChangeCredentialRequest - { - CurrentSecret = _oldPassword!, - NewSecret = _newPassword! - }; - } - else + var request = new ChangeCredentialRequest { - request = new ChangeCredentialRequest - { - NewSecret = _newPassword! - }; - } + CurrentSecret = UserKey is null ? _oldPassword : null, + NewSecret = _newPassword! + }; - UAuthResult result; - if (UserKey is null) - { - result = await UAuthClient.Credentials.ChangeMyAsync(request); - } - else - { - result = await UAuthClient.Credentials.ChangeUserAsync(UserKey.Value, request); - } + _isSubmitting = true; - if (result.IsSuccess) + try { - Snackbar.Add("Password changed successfully", Severity.Success); + UAuthResult result = + UserKey is null + ? await UAuthClient.Credentials.ChangeMyAsync(request) + : await UAuthClient.Credentials.ChangeUserAsync( + UserKey.Value, request); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to update password.", + Severity.Error); + return; + } + _oldPassword = null; _newPassword = null; _newPasswordCheck = null; + + Snackbar.Add( + UserKey is null + ? "Password changed successfully." + : "User password updated successfully.", + Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); } - else + catch (Exception) + { + Snackbar.Add( + "An unexpected error occurred while updating the password.", + Severity.Error); + } + finally { - Snackbar.Add(result.ErrorText ?? "An error occurred while changing password", Severity.Error); + _isSubmitting = false; } } - private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; + private string? PasswordMatch(string? value) + { + return _newPassword == value + ? null + : "Passwords don't match."; + } - private void Cancel() => MudDialog.Cancel(); + private void Cancel() + { + if (!_isSubmitting) + MudDialog.Cancel(); + } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor.css new file mode 100644 index 00000000..6303ec6e --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/CredentialDialog.razor.css @@ -0,0 +1,134 @@ +.lab-credential-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-credential-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-credential-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-credential-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + letter-spacing: -.3px; + } + + .lab-credential-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-credential-content { + display: flex; + flex-direction: column; + gap: 20px; + padding: 4px 0; +} + +.lab-credential-section { + display: flex; + flex-direction: column; + gap: 17px; +} + +.lab-credential-section-heading { + display: flex; + align-items: flex-start; + gap: 12px; +} + + .lab-credential-section-heading > div { + display: flex; + flex-direction: column; + gap: 4px; + } + + .lab-credential-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-credential-section-heading span { + color: var(--mud-palette-text-secondary); + font-size: .73rem; + line-height: 1.5; + } + +.lab-credential-fields { + display: flex; + flex-direction: column; + gap: 15px; +} + +.lab-credential-hint { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-credential-hint span { + font-size: .72rem; + line-height: 1.6; + } + +.lab-credential-security-note { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; +} + + .lab-credential-security-note > div { + display: flex; + flex-direction: column; + gap: 5px; + } + + .lab-credential-security-note strong { + font-size: .78rem; + font-weight: 750; + } + + .lab-credential-security-note span { + color: var(--mud-palette-text-secondary); + font-size: .72rem; + line-height: 1.6; + } + +.lab-credential-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + width: 100%; +} + +@media (max-width: 600px) { + .lab-credential-content { + gap: 17px; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor index 0d631533..97becc06 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor @@ -1,106 +1,248 @@ -@using CodeBeam.UltimateAuth.Core.Contracts -@using CodeBeam.UltimateAuth.Users.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService - + - Identifier Management - - @if (UserKey is null) +
+
+ +
+
+ Manage Identifiers + + Manage usernames, email addresses and other sign-in identifiers. + +
+
+
+ + +
+ @if (PreviewMode) { - User: @AuthState?.Identity?.DisplayName + + Preview mode is active. Identifier changes are disabled. + } - else + +
+
+ Linked Identifiers + Identifiers associated with this identity. +
+ + +
+ + @if (_loading) { - UserKey: @UserKey.Value + } - - - - - - - Identifiers - - - - - - - - - - - - - - - - - - - - - - - - - @if (context.Item.IsPrimary) - { - - - - } - else + else if (_loadError is not null) + { + + @_loadError + + } + else if (_identifiers.Count == 0) + { +
+ + No identifiers found + No identifiers are currently available for this identity. +
+ } + else + { +
+ @foreach (var identifier in _identifiers) + { +
+
+
+ +
+ +
+
+ @identifier.Type + + @if (identifier.IsPrimary) + { + + Primary + + } +
+ + + @identifier.Value + + +
+ @if (identifier.IsVerified) + { + + Verified + } + else + { + + Not verified + } +
+
+
+ +
+ + Edit + + + @if (identifier.IsPrimary) + { + + Unset Primary + + } + else + { + + Set Primary + + } + + @if (!identifier.IsVerified) + { + + Verify + + } + + + + +
+ + @if (_editingId == identifier.Id) { - - - +
+ + +
+ + Cancel + + + Save + +
+
} +
+ } +
+ } + + + +
+ Add Identifier + Attach an additional identifier to this account. +
+ +
+ + + + + +
+
- Cancel +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.cs index 0612671d..49629010 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.cs @@ -9,308 +9,320 @@ namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; public partial class IdentifierDialog { - private MudDataGrid? _grid; - private UserIdentifierType _newIdentifierType; + private readonly List _identifiers = []; + private readonly UserIdentifierType[] _identifierTypes = + Enum.GetValues(); + + private UserIdentifierType _newIdentifierType = UserIdentifierType.Email; private string? _newIdentifierValue; private bool _newIdentifierPrimary; - private bool _loading = false; - private bool _reloadQueued; + + private Guid? _editingId; + private string? _editingValue; + + private bool _loading; + private string? _loadError; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } [Parameter] public UserKey? UserKey { get; set; } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender); - - if (firstRender) - { - var result = await UAuthClient.Identifiers.GetMyAsync(); - if (result != null && result.IsSuccess && result.Value != null) - { - await ReloadAsync(); - StateHasChanged(); - } - } - } + [Parameter] + public bool PreviewMode { get; set; } - private async Task> LoadServerData(GridState state, CancellationToken ct) + protected override async Task OnInitializedAsync() { - var sort = state.SortDefinitions?.FirstOrDefault(); - - var req = new PageRequest - { - PageNumber = state.Page + 1, - PageSize = state.PageSize, - SortBy = sort?.SortBy, - Descending = sort?.Descending ?? false - }; - - UAuthResult> res; - - if (UserKey is null) + if (PreviewMode) { - res = await UAuthClient.Identifiers.GetMyAsync(req); - } - else - { - res = await UAuthClient.Identifiers.GetUserAsync(UserKey.Value, req); + LoadPreviewData(); + return; } - if (!res.IsSuccess || res.Value is null) - { - Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); - - return new GridData - { - Items = Array.Empty(), - TotalItems = 0 - }; - } + await ReloadAsync(); + } - return new GridData - { - Items = res.Value.Items, - TotalItems = res.Value.TotalCount - }; + private void LoadPreviewData() + { + _identifiers.Clear(); + + // Preview data is presentation-only. + // Real identifier models are loaded from UltimateAuth. + _identifiers.Add(new UserIdentifierInfo + { + Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), + Type = UserIdentifierType.Username, + Value = "alex", + IsPrimary = true, + IsVerified = true + }); + + _identifiers.Add(new UserIdentifierInfo + { + Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), + Type = UserIdentifierType.Email, + Value = "alex@example.com", + IsPrimary = false, + IsVerified = true + }); } private async Task ReloadAsync() { - if (_loading) - { - _reloadQueued = true; - return; - } - - if (_grid is null) + if (_loading || PreviewMode) return; _loading = true; - await InvokeAsync(StateHasChanged); - await Task.Delay(300); + _loadError = null; try { - await _grid.ReloadServerData(); + var collected = new List(); + const int pageSize = 50; + var pageNumber = 1; + + while (true) + { + var request = new PageRequest + { + PageNumber = pageNumber, + PageSize = pageSize + }; + + UAuthResult> result = + UserKey is null + ? await UAuthClient.Identifiers.GetMyAsync(request) + : await UAuthClient.Identifiers.GetUserAsync( + UserKey.Value, request); + + if (!result.IsSuccess || result.Value is null) + { + _loadError = result.ErrorText + ?? "Unable to load identifiers."; + return; + } + + collected.AddRange(result.Value.Items); + + if (collected.Count >= result.Value.TotalCount || + result.Value.Items.Count() == 0) + break; + + pageNumber++; + } + + _identifiers.Clear(); + _identifiers.AddRange(collected); + } + catch (Exception) + { + _loadError = "An unexpected error occurred while loading identifiers."; } finally { _loading = false; + } + } - if (_reloadQueued) - { - _reloadQueued = false; - await ReloadAsync(); - } + private void BeginEdit(UserIdentifierInfo identifier) + { + if (PreviewMode || _loading) + return; - await InvokeAsync(StateHasChanged); - } + _editingId = identifier.Id; + _editingValue = identifier.Value; } - private async Task CommittedItemChanges(UserIdentifierInfo item) + private void CancelEdit() { - UpdateUserIdentifierRequest updateRequest = new() - { - Id = item.Id, - NewValue = item.Value - }; + _editingId = null; + _editingValue = null; + } - UAuthResult result; + private async Task SaveEditAsync() + { + if (_editingId is null || string.IsNullOrWhiteSpace(_editingValue)) + return; - if (UserKey is null) - { - result = await UAuthClient.Identifiers.UpdateMyAsync(updateRequest); - } - else + var request = new UpdateUserIdentifierRequest { - result = await UAuthClient.Identifiers.UpdateUserAsync(UserKey.Value, updateRequest); - } + Id = _editingId.Value, + NewValue = _editingValue.Trim() + }; - if (result.IsSuccess) - { - Snackbar.Add("Identifier updated successfully.", Severity.Success); - } - else - { - Snackbar.Add(result?.ErrorText ?? "Failed to update identifier", Severity.Error); - } + await ExecuteAsync( + () => UserKey is null + ? UAuthClient.Identifiers.UpdateMyAsync(request) + : UAuthClient.Identifiers.UpdateUserAsync( + UserKey.Value, request), + "Identifier updated successfully."); - await ReloadAsync(); - return DataGridEditFormAction.Close; + CancelEdit(); } - private async Task AddNewIdentifier() + private async Task AddNewIdentifierAsync() { - if (string.IsNullOrEmpty(_newIdentifierValue)) + if (string.IsNullOrWhiteSpace(_newIdentifierValue)) { - Snackbar.Add("Value cannot be empty", Severity.Warning); + Snackbar.Add("Identifier value is required.", Severity.Warning); return; } - AddUserIdentifierRequest request = new() + var request = new AddUserIdentifierRequest { Type = _newIdentifierType, - Value = _newIdentifierValue, + Value = _newIdentifierValue.Trim(), IsPrimary = _newIdentifierPrimary }; - UAuthResult result; + var success = await ExecuteAsync( + () => UserKey is null + ? UAuthClient.Identifiers.AddMyAsync(request) + : UAuthClient.Identifiers.AddUserAsync( + UserKey.Value, request), + "Identifier added successfully."); - if (UserKey is null) + if (success) { - result = await UAuthClient.Identifiers.AddMyAsync(request); - } - else - { - result = await UAuthClient.Identifiers.AddUserAsync(UserKey.Value, request); + _newIdentifierValue = null; + _newIdentifierPrimary = false; } + } - if (result.IsSuccess) - { - Snackbar.Add("Identifier added successfully.", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else - { - Snackbar.Add(result?.ErrorText ?? "Failed to add identifier.", Severity.Error); - } + private async Task SetPrimaryAsync(Guid id) + { + var request = new SetPrimaryUserIdentifierRequest { Id = id }; + + await ExecuteAsync( + () => UserKey is null + ? UAuthClient.Identifiers.SetMyPrimaryAsync(request) + : UAuthClient.Identifiers.SetUserPrimaryAsync( + UserKey.Value, request), + "Primary identifier updated."); + } + + private async Task UnsetPrimaryAsync(Guid id) + { + var request = new UnsetPrimaryUserIdentifierRequest { Id = id }; + + await ExecuteAsync( + () => UserKey is null + ? UAuthClient.Identifiers.UnsetMyPrimaryAsync(request) + : UAuthClient.Identifiers.UnsetUserPrimaryAsync( + UserKey.Value, request), + "Primary identifier removed."); } private async Task VerifyAsync(Guid id) { - var demoInfo = await DialogService.ShowMessageBoxAsync( - title: "Demo Verification", - markupMessage: (MarkupString) - """ - This Development sample bypasses ownership verification.

- No email or SMS proof is checked. The identifier will be marked - as verified for demonstration purposes only.

- Production applications must implement a real - IUserIdentifierVerifier. - """, - yesText: "Verify", noText: "Cancel", - options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); - - if (demoInfo != true) - { - Snackbar.Add("Verification cancelled", Severity.Info); + if (PreviewMode) return; - } - VerifyUserIdentifierRequest request = new() - { - Id = id, - Proof = UserKey is null ? "demo-verification-bypass" : null - }; + var confirmed = await DialogService.ShowMessageBoxAsync( + "Development Verification", + "This sample verification bypasses ownership proof. " + + "Only use this operation in a development environment " + + "with an explicitly configured demo verifier.", + yesText: "Continue", + noText: "Cancel"); - UAuthResult result; + if (confirmed != true) + return; - if (UserKey is null) + var request = new VerifyUserIdentifierRequest { - result = await UAuthClient.Identifiers.VerifyMyAsync(request); - } - else - { - result = await UAuthClient.Identifiers.VerifyUserAsync(UserKey.Value, request); - } + Id = id, + Proof = UserKey is null + ? "demo-verification-bypass" + : null + }; - if (result.IsSuccess) - { - Snackbar.Add("Identifier verified successfully.", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else - { - Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier.", Severity.Error); - } + await ExecuteAsync( + () => UserKey is null + ? UAuthClient.Identifiers.VerifyMyAsync(request) + : UAuthClient.Identifiers.VerifyUserAsync( + UserKey.Value, request), + "Identifier verified."); } - private async Task SetPrimaryAsync(Guid id) + private async Task DeleteIdentifierAsync(Guid id) { - SetPrimaryUserIdentifierRequest request = new() { Id = id }; - UAuthResult result; + var confirmed = await DialogService.ShowMessageBoxAsync( + "Delete Identifier", + "Are you sure you want to remove this identifier?", + yesText: "Delete", + noText: "Cancel"); - if (UserKey is null) - { - result = await UAuthClient.Identifiers.SetMyPrimaryAsync(request); - } - else - { - result = await UAuthClient.Identifiers.SetUserPrimaryAsync(UserKey.Value, request); - } + if (confirmed != true) + return; - if (result.IsSuccess) - { - Snackbar.Add("Primary identifier set successfully.", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else - { - Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier.", Severity.Error); - } + var request = new DeleteUserIdentifierRequest { Id = id }; + + await ExecuteAsync( + () => UserKey is null + ? UAuthClient.Identifiers.DeleteMyAsync(request) + : UAuthClient.Identifiers.DeleteUserAsync( + UserKey.Value, request), + "Identifier deleted."); } - private async Task UnsetPrimaryAsync(Guid id) + private async Task ExecuteAsync( + Func> operation, + string successMessage) { - UnsetPrimaryUserIdentifierRequest request = new() { Id = id }; - UAuthResult result; + if (_loading || PreviewMode) + return false; - if (UserKey is null) - { - result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(request); - } - else - { - result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(UserKey.Value, request); - } + _loading = true; - if (result.IsSuccess) - { - Snackbar.Add("Primary identifier unset successfully.", Severity.Success); - await ReloadAsync(); - StateHasChanged(); - } - else + try { - Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier.", Severity.Error); - } - } + var result = await operation(); - private async Task DeleteIdentifier(Guid id) - { - DeleteUserIdentifierRequest request = new() { Id = id }; - UAuthResult result; + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Identifier operation failed.", + Severity.Error); + return false; + } - if (UserKey is null) - { - result = await UAuthClient.Identifiers.DeleteMyAsync(request); + Snackbar.Add(successMessage, Severity.Success); } - else + catch (Exception) { - result = await UAuthClient.Identifiers.DeleteUserAsync(UserKey.Value, request); + Snackbar.Add( + "An unexpected error occurred.", + Severity.Error); + return false; } - - if (result.IsSuccess) + finally { - Snackbar.Add("Identifier deleted successfully.", Severity.Success); - await ReloadAsync(); - StateHasChanged(); + _loading = false; } - else + + await ReloadAsync(); + return true; + } + + private static string GetIdentifierIcon(UserIdentifierType type) + { + return type switch { - Snackbar.Add(result?.ErrorText ?? "Failed to delete identifier", Severity.Error); - } + UserIdentifierType.Username => Icons.Material.Filled.PersonOutline, + UserIdentifierType.Email => Icons.Material.Filled.AlternateEmail, + UserIdentifierType.Phone => Icons.Material.Filled.Phone, + _ => Icons.Material.Filled.Badge + }; } - private void Cancel() => MudDialog.Cancel(); + private void Cancel() + { + if (!_loading) + MudDialog.Cancel(); + } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.css new file mode 100644 index 00000000..64f4f747 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/IdentifierDialog.razor.css @@ -0,0 +1,204 @@ +.lab-identifier-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-identifier-header-icon, +.lab-identifier-type-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + border-radius: 11px; +} + +.lab-identifier-header-icon { + width: 40px; + height: 40px; +} + +.lab-identifier-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-identifier-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-identifier-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-identifier-content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.lab-identifier-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.lab-identifier-section-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + + .lab-identifier-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-identifier-section-heading span { + color: var(--mud-palette-text-secondary); + font-size: .73rem; + line-height: 1.5; + } + +.lab-identifier-list { + display: flex; + flex-direction: column; + gap: 11px; +} + +.lab-identifier-card { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + overflow: hidden; + background: var(--mud-palette-surface); +} + +.lab-identifier-card-main { + display: flex; + align-items: flex-start; + gap: 13px; + padding: 15px; +} + +.lab-identifier-type-icon { + width: 37px; + height: 37px; +} + +.lab-identifier-card-details { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; + flex: 1; +} + +.lab-identifier-card-label { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + + .lab-identifier-card-label strong { + font-size: .84rem; + } + +.lab-identifier-value { + font-size: .82rem; + overflow-wrap: anywhere; +} + +.lab-identifier-status { + display: flex; + align-items: center; + gap: 5px; + font-size: .72rem; + color: var(--mud-palette-text-secondary); +} + +.lab-identifier-card-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + padding: 8px 12px; + background: var(--mud-palette-background); + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-identifier-edit { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-identifier-edit-actions, +.lab-identifier-footer { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.lab-identifier-add { + display: flex; + flex-direction: column; + gap: 15px; +} + +.lab-identifier-add-footer { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.lab-identifier-empty { + display: flex; + align-items: center; + flex-direction: column; + gap: 10px; + text-align: center; + padding: 30px 15px; + border: 1px dashed var(--mud-palette-lines-default); + border-radius: 12px; +} + + .lab-identifier-empty strong { + font-size: .85rem; + } + + .lab-identifier-empty span { + font-size: .75rem; + color: var(--mud-palette-text-secondary); + } + +@media (max-width: 600px) { + .lab-identifier-card-main { + padding: 12px; + } + + .lab-identifier-card-actions { + padding: 8px; + } + + .lab-identifier-add-footer { + align-items: stretch; + flex-direction: column; + } + + .lab-identifier-add-footer ::deep .mud-button-root { + width: 100%; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor index 8e0df863..45c130f8 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor @@ -1,46 +1,148 @@ @using CodeBeam.UltimateAuth.Authorization.Contracts -@using CodeBeam.UltimateAuth.Core.Defaults -@using System.Reflection - @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar - + - Role Permissions - @Role.Name +
+
+ +
+
+ Role Permissions + + Configure permissions for @(PreviewMode ? PreviewRoleName : Role?.Name ?? "this role"). + +
+
- @* For Debug *@ - @* Current Permissions: @string.Join(", ", Role.Permissions) *@ - - @foreach (var group in _groups) +
+ @if (PreviewMode) { - - - - - @group.Name (@group.Items.Count(x => x.Selected)/@group.Items.Count) - - - - - @foreach (var perm in group.Items) + + Preview mode is active. You can explore permission + selection, but changes cannot be saved. + + } + +
+
+ Selected Permissions + @SelectedCount / @TotalCount +
+ +
+ +
+ + + + Reset + +
+ +
+ @foreach (var group in FilteredGroups) + { +
+
+ + +
+ @group.Name + @group.Items.Count(x => x.Selected) of @group.Items.Count selected +
+ + +
+ + @if (group.Expanded) { - - - +
+ @foreach (var permission in GetVisibleItems(group)) + { +
+ + @permission.Value +
+ } +
} - - - - } - +
+ } + + @if (FilteredGroups.Count == 0) + { +
+ + No permissions found + Try a different search term. +
+ } +
+ +
+ + + Permission groups are organized by resource. + Selecting a group selects its catalog permissions. + Changes take effect according to the authorization + evaluation and caching policies of the application. + +
+
- Cancel - Save +
+ + Cancel + + + + Save Permissions + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.cs index aeb9c1c9..d82e214a 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.cs @@ -10,40 +10,103 @@ public partial class PermissionDialog private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public RoleInfo Role { get; set; } = default!; + public RoleInfo? Role { get; set; } - private List _groups = new(); + [Parameter] + public bool PreviewMode { get; set; } + + [Parameter] + public string PreviewRoleName { get; set; } = "Editor"; + + private List _groups = []; + private HashSet _originalSelection = []; + + private string? _search; + private bool _isSaving; + + private int SelectedCount => + _groups.Sum(g => g.Items.Count(x => x.Selected)); + + private int TotalCount => + _groups.Sum(g => g.Items.Count); + + private double SelectionPercentage => + TotalCount == 0 + ? 0 + : SelectedCount * 100.0 / TotalCount; + + private bool HasChanges => + !_originalSelection.SetEquals( + _groups.SelectMany(g => g.Items) + .Where(x => x.Selected) + .Select(x => x.Value)); + + private List FilteredGroups => + _groups + .Where(g => + string.IsNullOrWhiteSpace(_search) || + g.Name.Contains(_search, StringComparison.OrdinalIgnoreCase) || + g.Items.Any(x => x.Value.Contains( + _search, StringComparison.OrdinalIgnoreCase))) + .ToList(); protected override void OnInitialized() { var catalog = UAuthPermissionCatalog.GetAdminPermissions(); - var expanded = PermissionExpander.Expand(Role.Permissions, catalog); - var selected = expanded.Select(x => x.Value).ToHashSet(); + + HashSet selected; + + if (PreviewMode) + { + selected = catalog + .Take(Math.Min(6, catalog.Count())) + .ToHashSet(StringComparer.Ordinal); + } + else + { + if (Role is null) + throw new InvalidOperationException( + "Role is required outside preview mode."); + + var expanded = PermissionExpander.Expand( + Role.Permissions, + catalog); + + selected = expanded + .Select(x => x.Value) + .ToHashSet(StringComparer.Ordinal); + } + + _originalSelection = new HashSet( + selected, + StringComparer.Ordinal); _groups = catalog .GroupBy(p => p.Split('.')[0]) - .Select(g => new PermissionGroup + .Select(group => new PermissionGroup { - Name = g.Key, - Items = g.Select(p => new PermissionItem - { - Value = p, - Selected = selected.Contains(p) - }).ToList() + Name = group.Key, + Expanded = false, + Items = group + .Select(permission => new PermissionItem + { + Value = permission, + Selected = selected.Contains(permission) + }) + .ToList() }) .OrderBy(x => x.Name) .ToList(); } - private void ToggleGroup(PermissionGroup group, bool value) + private IEnumerable GetVisibleItems( + PermissionGroup group) { - foreach (var item in group.Items) - item.Selected = value; - } + if (string.IsNullOrWhiteSpace(_search)) + return group.Items; - private void TogglePermission(PermissionItem item, bool value) - { - item.Selected = value; + return group.Items.Where(x => + x.Value.Contains(_search, StringComparison.OrdinalIgnoreCase)); } private bool? GetGroupState(PermissionGroup group) @@ -59,60 +122,107 @@ private void TogglePermission(PermissionItem item, bool value) return null; } - private async Task Save() + private void ToggleGroup(PermissionGroup group, bool selected) { - var permissions = _groups.SelectMany(g => g.Items).Where(x => x.Selected).Select(x => Permission.From(x.Value)).ToList(); + if (_isSaving) + return; - var req = new SetRolePermissionsRequest - { - RoleId = Role.Id, - Permissions = permissions - }; + // A group checkbox always affects the complete group, + // even when search filtering is active. + foreach (var item in group.Items) + item.Selected = selected; + } - var result = await UAuthClient.Authorization.SetRolePermissionsAsync(req); + private void TogglePermission( + PermissionItem permission, + bool selected) + { + if (_isSaving) + return; - if (!result.IsSuccess) - { - Snackbar.Add(result.ErrorText ?? "Failed to update permissions", Severity.Error); + permission.Selected = selected; + } + + private void ToggleExpanded(PermissionGroup group) + { + group.Expanded = !group.Expanded; + } + + private void ResetSelection() + { + if (_isSaving) return; - } - var result2 = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery() { Search = Role.Name }); - if (result2.Value?.Items is not null) + foreach (var item in _groups.SelectMany(g => g.Items)) { - Role = result2.Value.Items.First(); + item.Selected = _originalSelection.Contains(item.Value); } - - Snackbar.Add("Permissions updated", Severity.Success); - RefreshUI(); } - private void RefreshUI() + private async Task SaveAsync() { - var catalog = UAuthPermissionCatalog.GetAdminPermissions(); - var expanded = PermissionExpander.Expand(Role.Permissions, catalog); - var selected = expanded.Select(x => x.Value).ToHashSet(); + if (PreviewMode || _isSaving || Role is null || !HasChanges) + return; + + var permissions = _groups + .SelectMany(g => g.Items) + .Where(x => x.Selected) + .Select(x => Permission.From(x.Value)) + .ToList(); - foreach (var group in _groups) + var request = new SetRolePermissionsRequest { - foreach (var item in group.Items) + RoleId = Role.Id, + Permissions = permissions + }; + + _isSaving = true; + + try + { + var result = await UAuthClient.Authorization + .SetRolePermissionsAsync(request); + + if (!result.IsSuccess) { - item.Selected = selected.Contains(item.Value); + Snackbar.Add( + result.ErrorText ?? "Failed to update permissions.", + Severity.Error); + return; } - } - StateHasChanged(); + Snackbar.Add( + "Role permissions updated successfully.", + Severity.Success); + + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception) + { + Snackbar.Add( + "An unexpected error occurred while updating permissions.", + Severity.Error); + } + finally + { + _isSaving = false; + } } - private void Cancel() => MudDialog.Cancel(); + private void Cancel() + { + if (!_isSaving) + MudDialog.Cancel(); + } - private class PermissionGroup + private sealed class PermissionGroup { public string Name { get; set; } = ""; - public List Items { get; set; } = new(); + public bool Expanded { get; set; } + public List Items { get; set; } = []; } - private class PermissionItem + private sealed class PermissionItem { public string Value { get; set; } = ""; public bool Selected { get; set; } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.css new file mode 100644 index 00000000..d40cea9f --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/PermissionDialog.razor.css @@ -0,0 +1,195 @@ +.lab-permission-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-permission-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-permission-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-permission-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-permission-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-permission-content { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +.lab-permission-summary { + display: flex; + flex-direction: column; + gap: 11px; + padding: 16px; + background: var(--mud-palette-background); + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; +} + + .lab-permission-summary > div:first-child { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .lab-permission-summary span { + font-size: .77rem; + color: var(--mud-palette-text-secondary); + } + + .lab-permission-summary strong { + font-size: .9rem; + font-weight: 750; + } + +.lab-permission-toolbar { + display: flex; + align-items: center; + gap: 12px; +} + + .lab-permission-toolbar ::deep .mud-input-control { + flex: 1; + min-width: 0; + } + +.lab-permission-groups { + display: flex; + flex-direction: column; + gap: 10px; +} + +.lab-permission-group { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + overflow: hidden; +} + +.lab-permission-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 13px; + background: var(--mud-palette-background); +} + +.lab-permission-group-heading { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + + .lab-permission-group-heading strong { + font-size: .83rem; + font-weight: 750; + } + + .lab-permission-group-heading span { + font-size: .7rem; + color: var(--mud-palette-text-secondary); + } + +.lab-permission-items { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 12px; + padding: 12px 15px; + border-top: 1px solid var(--mud-palette-lines-default); +} + +.lab-permission-item { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 5px 0; +} + + .lab-permission-item span { + font-size: .75rem; + overflow-wrap: anywhere; + } + +.lab-permission-empty { + display: flex; + align-items: center; + flex-direction: column; + gap: 10px; + padding: 30px 15px; + text-align: center; + color: var(--mud-palette-text-secondary); +} + + .lab-permission-empty strong { + font-size: .85rem; + } + + .lab-permission-empty span { + font-size: .73rem; + } + +.lab-permission-hint { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-permission-hint span { + font-size: .72rem; + line-height: 1.6; + } + +.lab-permission-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + width: 100%; +} + +@media (max-width: 600px) { + .lab-permission-items { + grid-template-columns: 1fr; + } + + .lab-permission-summary { + padding: 13px; + } + + .lab-permission-toolbar { + gap: 6px; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor index 26863c14..8cbf17d3 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor @@ -1,92 +1,187 @@ @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar -@inject IDialogService DialogService - + - Identifier Management - - @if (UserKey is null) - { - User: @AuthState?.Identity?.DisplayName - } - else - { - UserKey: @UserKey.Value - } - +
+
+ +
+ +
+ Edit Profile + + Update identity details and personal preferences. + +
+
+ - - - - - - Name - - - - - - - - - - - - - - - - - - - Personal - - - - - - - - - - - - - - - - - - - Localization - - - - - - - - - - - @foreach (var tz in TimeZoneInfo.GetSystemTimeZones()) - { - @tz.Id - @tz.DisplayName - } - - - - - - - - - + @if (_isLoading) + { + + } + else if (_loadError is not null) + { + + @_loadError + + } + else + { + +
+ @if (PreviewMode) + { + + Preview mode is active. Profile changes cannot be saved. + + } + +
+
+ +
+ Basic Information + How this identity appears throughout the application. +
+
+ +
+ + + + +
+ +
+
+
+ + + +
+
+ +
+ Personal Details + Optional information associated with this profile. +
+
+ +
+ + + + +
+ +
+
+
+ + + +
+
+ +
+ Localization + Language, time zone and culture preferences. +
+
+ +
+ + + + +
+ + @foreach (var tz in _timeZones) + { + + @tz.Id + + } + +
+
+
+
+
+ }
+ - Cancel - Save +
+ + Cancel + + + + Save Changes + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor.cs index 673a3610..c185ed5a 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor.cs @@ -10,6 +10,7 @@ namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; public partial class ProfileDialog { private MudForm? _form; + private string? _firstName; private string? _lastName; private string? _displayName; @@ -20,95 +21,149 @@ public partial class ProfileDialog private string? _timeZone; private string? _culture; + private bool _isLoading = true; + private bool _isSaving; + private string? _loadError; + + private readonly IReadOnlyList _timeZones = + TimeZoneInfo.GetSystemTimeZones(); + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } [Parameter] public UserKey? UserKey { get; set; } + [Parameter] + public bool PreviewMode { get; set; } + + [Parameter] + public string? PreviewDisplayName { get; set; } + protected override async Task OnInitializedAsync() { - UAuthResult result; + if (PreviewMode) + { + LoadPreviewData(); + _isLoading = false; + return; + } - if (UserKey is null) + try { - result = await UAuthClient.Users.GetMeAsync(); + UAuthResult result = UserKey is null + ? await UAuthClient.Users.GetMeAsync() + : await UAuthClient.Users.GetUserAsync(UserKey.Value); + + if (!result.IsSuccess || result.Value is null) + { + _loadError = result.ErrorText + ?? "Unable to load profile information."; + return; + } + + var profile = result.Value; + + _firstName = profile.FirstName; + _lastName = profile.LastName; + _displayName = profile.DisplayName; + _birthDate = profile.BirthDate? + .ToDateTime(TimeOnly.MinValue); + _gender = profile.Gender; + _bio = profile.Bio; + _language = profile.Language; + _timeZone = profile.TimeZone; + _culture = profile.Culture; } - else + catch (Exception) { - result = await UAuthClient.Users.GetUserAsync(UserKey.Value); + _loadError = "An unexpected error occurred while loading the profile."; } - - if (result.IsSuccess && result.Value is not null) + finally { - var p = result.Value; - - _firstName = p.FirstName; - _lastName = p.LastName; - _displayName = p.DisplayName; - - _gender = p.Gender; - _birthDate = p.BirthDate?.ToDateTime(TimeOnly.MinValue); - _bio = p.Bio; - - _language = p.Language; - _timeZone = p.TimeZone; - _culture = p.Culture; + _isLoading = false; } } + private void LoadPreviewData() + { + _displayName = PreviewDisplayName ?? "Alex Morgan"; + _firstName = "Alex"; + _lastName = "Morgan"; + _birthDate = null; + _gender = null; + _bio = "Exploring identity management with UltimateAuth."; + _language = "en"; + _culture = "en-US"; + _timeZone = "UTC"; + } + private async Task SaveAsync() { - if (AuthState is null || AuthState.Identity is null) - { - Snackbar.Add("No AuthState found.", Severity.Error); + if (PreviewMode || _isLoading || _isSaving || _loadError is not null) return; - } - if (_form is not null) - { - await _form.ValidateAsync(); - if (!_form.IsValid) - return; - } + if (_form is null) + return; + + await _form.ValidateAsync(); + + if (!_form.IsValid) + return; var request = new UpdateProfileRequest { - FirstName = _firstName, - LastName = _lastName, - DisplayName = _displayName, - BirthDate = _birthDate.HasValue ? DateOnly.FromDateTime(_birthDate.Value) : null, - Gender = _gender, + FirstName = _firstName?.Trim(), + LastName = _lastName?.Trim(), + DisplayName = _displayName?.Trim(), + BirthDate = _birthDate.HasValue + ? DateOnly.FromDateTime(_birthDate.Value) + : null, + Gender = _gender?.Trim(), Bio = _bio, - Language = _language, + Language = _language?.Trim(), TimeZone = _timeZone, - Culture = _culture + Culture = _culture?.Trim() }; - UAuthResult result; + _isSaving = true; - if (UserKey is null) + try { - result = await UAuthClient.Users.UpdateMyProfileAsync(request); - } - else - { - result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request); - } + UAuthResult result = UserKey is null + ? await UAuthClient.Users.UpdateMyProfileAsync(request) + : await UAuthClient.Users.UpdateUserProfileAsync( + UserKey.Value, request); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to update profile.", + Severity.Error); + return; + } - if (result.IsSuccess) - { - Snackbar.Add("Profile updated", Severity.Success); + Snackbar.Add("Profile updated successfully.", Severity.Success); MudDialog.Close(DialogResult.Ok(true)); } - else + catch (Exception) { - Snackbar.Add(result.ErrorText ?? "Failed to update profile", Severity.Error); + Snackbar.Add( + "An unexpected error occurred while saving the profile.", + Severity.Error); + } + finally + { + _isSaving = false; } } - private void Cancel() => MudDialog.Cancel(); + private void Cancel() + { + if (!_isSaving) + MudDialog.Cancel(); + } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor.css new file mode 100644 index 00000000..db0f0012 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ProfileDialog.razor.css @@ -0,0 +1,105 @@ +.lab-profile-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-profile-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-profile-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-profile-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + letter-spacing: -.3px; + } + + .lab-profile-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-profile-content { + display: flex; + flex-direction: column; + gap: 22px; + padding: 4px 0; +} + +.lab-profile-section { + display: flex; + flex-direction: column; + gap: 17px; +} + +.lab-profile-section-heading { + display: flex; + align-items: flex-start; + gap: 12px; +} + + .lab-profile-section-heading > div { + display: flex; + flex-direction: column; + gap: 4px; + } + + .lab-profile-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-profile-section-heading span { + font-size: .73rem; + line-height: 1.5; + color: var(--mud-palette-text-secondary); + } + +.lab-profile-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px; +} + +.lab-profile-full { + grid-column: 1 / -1; + min-width: 0; +} + +.lab-profile-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + width: 100%; +} + +@media (max-width: 600px) { + .lab-profile-grid { + grid-template-columns: 1fr; + } + + .lab-profile-full { + grid-column: auto; + } + + .lab-profile-content { + gap: 18px; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor index 4809f4f7..bcd8505e 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor @@ -1,34 +1,142 @@ @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar -@inject IDialogService DialogService -@inject IUAuthStateManager StateManager @inject NavigationManager Nav - + - Reset Credential +
+
+ +
+
+ Reset Password + + Recover access to your account securely. + +
+
- - - In this development sample, reset codes are written to the server console by a custom ICredentialResetNotifier. - Production applications should send them through a trusted email or SMS channel. - - - Reset request always returns ok even with not found users due to security reasons. - - - Request Reset - @if (_resetRequested) +
+ @if (PreviewMode) { - Your reset code is on the server console. - Use Reset Code + + Preview mode is active. No reset request will be sent. + } - + + @if (!_resetRequested) + { +
+
+ +
+ Forgot your password? + + Enter your username or email address to request + a password reset code. + +
+ + + +
+ + + For security, reset requests do not disclose + whether an account exists. + +
+ + + Request Reset Code + + } + else + { +
+
+ +
+ + Reset Request Received + + + If an eligible account matches the information + provided, a reset code will be generated. + + +
+ + @_identifier +
+
+ + @if (!PreviewMode) + { + + In this development sample, reset codes are + written to the server console by the configured + credential reset notifier. Production applications + should deliver codes through a trusted channel. + + } + + + Continue to Password Reset + + + + Use a Different Identifier + + } +
- Close +
+ + Close + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor.cs index 96251caf..4d4c43be 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor.cs @@ -2,39 +2,117 @@ using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Credentials.Contracts; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; using MudBlazor; namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; public partial class ResetDialog { - private bool _resetRequested = false; - private string? _identifier; - [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } + + [Parameter] + public bool PreviewMode { get; set; } + + private bool _resetRequested; + private bool _isSubmitting; + private string? _identifier; private async Task RequestResetAsync() { - var request = new BeginResetCredentialRequest - { - CredentialType = CredentialType.Password, - ResetCodeType = ResetCodeType.Code, - Identifier = _identifier ?? string.Empty - }; + if (_isSubmitting || string.IsNullOrWhiteSpace(_identifier)) + return; - var result = await UAuthClient.Credentials.BeginResetAsync(request); - if (!result.IsSuccess || result.Value is null) + if (PreviewMode) { - Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); + _resetRequested = true; return; } - _resetRequested = true; + _isSubmitting = true; + + try + { + var request = new BeginResetCredentialRequest + { + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code, + Identifier = _identifier.Trim() + }; + + var result = await UAuthClient.Credentials + .BeginResetAsync(request); + + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add( + result.ErrorText ?? + "Failed to request password reset.", + Severity.Error); + return; + } + + _resetRequested = true; + } + catch (Exception) + { + Snackbar.Add( + "Unable to request password reset.", + Severity.Error); + } + finally + { + _isSubmitting = false; + } } - private void Cancel() => MudDialog.Cancel(); + private Task HandleKeyDownAsync(KeyboardEventArgs args) + { + if (args.Key == "Enter") + return RequestResetAsync(); + + return Task.CompletedTask; + } + + private Task ContinueResetAsync() + { + if (PreviewMode) + { + Snackbar.Add( + "Navigation is disabled in preview mode.", + Severity.Info); + return Task.CompletedTask; + } + + if (!_resetRequested || string.IsNullOrWhiteSpace(_identifier)) + return Task.CompletedTask; + + var encodedIdentifier = Uri.EscapeDataString(_identifier.Trim()); + + MudDialog.Close(); + + Nav.NavigateTo( + $"/reset?uauth_identifier={encodedIdentifier}"); + + return Task.CompletedTask; + } + + private void StartOver() + { + if (_isSubmitting) + return; + + _resetRequested = false; + _identifier = null; + } + + private void Cancel() + { + if (!_isSubmitting) + MudDialog.Cancel(); + } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor.css new file mode 100644 index 00000000..75cbba2d --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResetDialog.razor.css @@ -0,0 +1,138 @@ +.lab-reset-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-reset-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-reset-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-reset-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-reset-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-reset-content { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +.lab-reset-intro, +.lab-reset-success { + display: flex; + align-items: center; + flex-direction: column; + gap: 11px; + text-align: center; + padding: 16px 12px 8px; +} + +.lab-reset-illustration, +.lab-reset-success-icon { + display: flex; + align-items: center; + justify-content: center; + width: 68px; + height: 68px; + border-radius: 18px; +} + +.lab-reset-illustration { + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); +} + +.lab-reset-success-icon { + color: var(--mud-palette-success); + background: var(--mud-palette-success-hover); +} + +.lab-reset-intro strong, +.lab-reset-success strong { + font-size: 1rem; + font-weight: 750; +} + +.lab-reset-intro > span, +.lab-reset-success > span { + max-width: 310px; + font-size: .78rem; + line-height: 1.65; + color: var(--mud-palette-text-secondary); +} + +.lab-reset-security-note { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-reset-security-note span { + font-size: .72rem; + line-height: 1.6; + } + +.lab-reset-identifier { + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + width: 100%; + padding: 12px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 10px; + background: var(--mud-palette-background); +} + + .lab-reset-identifier span { + font-size: .78rem; + overflow-wrap: anywhere; + } + +.lab-reset-actions { + display: flex; + justify-content: flex-end; + width: 100%; +} + +@media (max-width: 600px) { + .lab-reset-intro, + .lab-reset-success { + padding: 10px 4px 4px; + } + + .lab-reset-illustration, + .lab-reset-success-icon { + width: 60px; + height: 60px; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor index 448d9c1d..db13e910 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor @@ -2,53 +2,245 @@ @inject SampleProductApiService Api @inject ISnackbar Snackbar - - + - Resource Api - Sample demonstration of a resource. +
+
+ +
+
+ Resource API + + Explore authenticated API resource operations. + +
+
- Reload - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Add - - - - +
+ @if (PreviewMode) + { + + Preview mode is active. Products are examples and + no API requests will be sent. + + } + +
+
+ +
+ +
+ Protected Resource + + This sample demonstrates how an authenticated + client interacts with a protected Resource API. + +
+ + + API + +
+ +
+
+ Products + Browse and manage sample resource records. +
+ + +
+ + @if (_isLoading) + { + + } + else if (_loadError is not null) + { + + @_loadError + + } + else if (PreviewMode) + { +
+ @foreach (var product in _previewProducts) + { +
+
+ +
+ +
+ @product.Name + Product #@product.Id +
+ + + + +
+ } +
+ } + else + { + + + + + + +
+ + @context.Item.Name +
+
+
+ + + + + + + + + + + + + + +
+ + + + +
+ } + + @if (_editingProduct is not null) + { +
+
+ Edit Product + Update the selected resource record. +
+ + + +
+ + Cancel + + + + Save Changes + +
+
+ } + + + +
+ Create Product + Add a new record to the protected resource. +
+ +
+ + + + Add Product + +
+ +
+ + + Resource operations are performed through + SampleProductApiService. Authorization is enforced + by the Resource API, not by this dialog. + +
+
-
\ No newline at end of file + +
+ + Close + +
+
+
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor.cs index 5c861ecc..f7e0c4c2 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor.cs @@ -11,82 +11,218 @@ public partial class ResourceApiDialog private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } - private List _products = new List(); - private string? _newName = null; + [Parameter] + public bool PreviewMode { get; set; } + + private List _products = []; + private string? _newName; + + private SampleProduct? _editingProduct; + private string? _editingName; - protected override async Task OnAfterRenderAsync(bool firstRender) + private bool _isLoading; + private bool _isBusy; + private string? _loadError; + + private readonly List _previewProducts = + [ + new(1, "Sample Laptop"), + new(2, "Wireless Keyboard"), + new(3, "USB-C Hub"), + new(4, "External Monitor") + ]; + + protected override async Task OnInitializedAsync() { - if (firstRender) - { - _products = (await Api.GetAllAsync()).Value ?? new(); - StateHasChanged(); - } + if (PreviewMode) + return; + + await GetProductsAsync(); } - private async Task CommittedItemChanges(SampleProduct item) + private async Task GetProductsAsync() { - var result = await Api.UpdateAsync(item.Id, item); + if (PreviewMode || _isBusy) + return; + + _isLoading = true; + _loadError = null; - if (result.IsSuccess) + try { - Snackbar.Add("Product updated successfully", Severity.Success); + var result = await Api.GetAllAsync(); + + if (!result.IsSuccess) + { + _loadError = result.ErrorText ?? + "Failed to load products."; + return; + } + + _products = result.Value ?? []; } - else + catch (Exception) { - Snackbar.Add(result?.ErrorText ?? "Failed to update product.", Severity.Error); + _loadError = "Unable to load resource data."; } + finally + { + _isLoading = false; + } + } + + private void BeginEdit(SampleProduct product) + { + if (PreviewMode || _isBusy) + return; - return DataGridEditFormAction.Close; + _editingProduct = product; + _editingName = product.Name; } - private async Task GetProducts() + private void CancelEdit() { - var result = await Api.GetAllAsync(); + _editingProduct = null; + _editingName = null; + } + + private async Task SaveProductAsync() + { + if (PreviewMode || _isBusy || _editingProduct is null || + string.IsNullOrWhiteSpace(_editingName)) + return; + + _isBusy = true; + bool success = false; + + try + { + var product = new SampleProduct + { + Id = _editingProduct.Id, + Name = _editingName.Trim() + }; + + var result = await Api.UpdateAsync(product.Id, product); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to update product.", + Severity.Error); + return; + } - if (result.IsSuccess) + success = true; + Snackbar.Add( + "Product updated successfully.", + Severity.Success); + CancelEdit(); + } + catch (Exception) { - _products = result.Value ?? new(); + Snackbar.Add("Unable to update product.", Severity.Error); } - else + finally { - Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error); + _isBusy = false; } + + if (success) + await GetProductsAsync(); } - private async Task CreateProduct() + private async Task CreateProductAsync() { - var product = new SampleProduct + if (PreviewMode || _isBusy || + string.IsNullOrWhiteSpace(_newName)) + return; + + _isBusy = true; + bool success = false; + + try { - Name = _newName - }; + var product = new SampleProduct + { + Name = _newName.Trim() + }; + + var result = await Api.CreateAsync(product); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to create product.", + Severity.Error); + return; + } - var result = await Api.CreateAsync(product); + success = true; + _newName = null; - if (result.IsSuccess) + Snackbar.Add( + "Product created successfully.", + Severity.Success); + } + catch (Exception) { - Snackbar.Add("New product created."); - _products = (await Api.GetAllAsync()).Value ?? new(); + Snackbar.Add("Unable to create product.", Severity.Error); } - else + finally { - Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error); + _isBusy = false; } + + if (success) + await GetProductsAsync(); } - private async Task DeleteProduct(int id) + private async Task DeleteProductAsync(int id) { - var result = await Api.DeleteAsync(id); + if (PreviewMode || _isBusy) + return; - if (result.IsSuccess) + _isBusy = true; + bool success = false; + + try + { + var result = await Api.DeleteAsync(id); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to delete product.", + Severity.Error); + return; + } + + success = true; + Snackbar.Add( + "Product deleted successfully.", + Severity.Success); + } + catch (Exception) { - Snackbar.Add("Product deleted succesfully.", Severity.Success); - _products = (await Api.GetAllAsync()).Value ?? new(); + Snackbar.Add("Unable to delete product.", Severity.Error); } - else + finally { - Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error); + _isBusy = false; } + + if (success) + await GetProductsAsync(); } + + private void Close() + { + if (!_isBusy) + MudDialog.Close(); + } + + private sealed record PreviewProduct(int Id, string Name); } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor.css new file mode 100644 index 00000000..72e26b95 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/ResourceApiDialog.razor.css @@ -0,0 +1,228 @@ +.lab-resource-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-resource-header-icon, +.lab-resource-overview-icon, +.lab-resource-card-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-resource-header-icon { + width: 40px; + height: 40px; +} + +.lab-resource-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-resource-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-resource-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-resource-content { + display: flex; + flex-direction: column; + gap: 19px; + min-width: 0; +} + +.lab-resource-overview { + display: flex; + align-items: center; + gap: 12px; + padding: 15px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + background: var(--mud-palette-background); +} + +.lab-resource-overview-icon { + width: 43px; + height: 43px; +} + +.lab-resource-overview-info { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 0; +} + + .lab-resource-overview-info strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-resource-overview-info span { + font-size: .73rem; + line-height: 1.5; + color: var(--mud-palette-text-secondary); + } + +.lab-resource-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lab-resource-section-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + + .lab-resource-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-resource-section-heading span { + font-size: .73rem; + line-height: 1.5; + color: var(--mud-palette-text-secondary); + } + +.lab-resource-grid { + overflow-x: auto; +} + +.lab-resource-grid-product { + display: flex; + align-items: center; + gap: 9px; +} + + .lab-resource-grid-product strong { + font-size: .8rem; + } + +.lab-resource-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.lab-resource-card { + display: flex; + align-items: center; + gap: 12px; + padding: 13px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; +} + +.lab-resource-card-icon { + width: 38px; + height: 38px; +} + +.lab-resource-card-info { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 0; +} + + .lab-resource-card-info strong { + font-size: .82rem; + } + + .lab-resource-card-info span { + font-size: .72rem; + color: var(--mud-palette-text-secondary); + } + +.lab-resource-create { + display: flex; + align-items: flex-start; + gap: 12px; +} + + .lab-resource-create ::deep .mud-input-control { + flex: 1; + min-width: 0; + } + + .lab-resource-create ::deep .mud-button-root { + flex-shrink: 0; + min-height: 40px; + } + +.lab-resource-edit { + display: flex; + flex-direction: column; + gap: 13px; + padding: 15px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; +} + +.lab-resource-edit-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.lab-resource-hint { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-resource-hint span { + font-size: .72rem; + line-height: 1.6; + } + +.lab-resource-actions { + display: flex; + justify-content: flex-end; + width: 100%; +} + +@media (max-width: 600px) { + .lab-resource-overview { + flex-wrap: wrap; + } + + .lab-resource-create { + flex-direction: column; + align-items: stretch; + } + + .lab-resource-create ::deep .mud-button-root { + width: 100%; + } + + .lab-resource-card { + flex-wrap: wrap; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor index b78db16f..e105459d 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor @@ -4,78 +4,252 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService - - + - Role Management - Manage system roles +
+
+ +
+
+ Role Management + + Create roles and manage their permissions. + +
+
- +
+ @if (PreviewMode) + { + + Preview mode is active. Role changes are disabled. + + } + +
+
+ System Roles + Roles available in the authorization system. +
+ +
+ + @if (PreviewMode) + { +
+ @foreach (var role in _previewRoles) + { +
+
+
+ +
+
+ @role.Name + @role.Description +
+ + @role.PermissionCount permissions + +
+
+ + Rename + + + Permissions + + + +
+
+ } +
+ } + else + { + + + + +
+ + @context.Item.Name +
+
+
+ + + + + @GetPermissionCount(context.Item) + + + + + + + @if (context.Item.Permissions.Any(x => x.IsWildcard)) + { + + Wildcard + + } + else + { + — + } + + + + + + + + + + + + + + + + + + +
- - - Roles - - - - + + + +
+ } - - - - - - @GetPermissionCount(context.Item) - - - - - - - - - - - - - + @if (_editingRole is not null) + { +
+
+ Rename Role + Change the role name without changing its permissions. +
+ +
+ + Cancel + + + Save Name + +
+
+ } - - - + - - - -
-
-
-
+
+ Create Role + Add a new role to the authorization system. +
- - - - +
+ - - - - - - + + Create Role + +
- - Create - - - - +
+ + + Roles define authorization groups. Permissions can be + configured after creating a role. + +
+
- Close +
+ + Close + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor.cs index 6447c7c4..cd04da2d 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor.cs @@ -8,20 +8,39 @@ namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; public partial class RoleDialog { private MudDataGrid? _grid; + private bool _loading; private string? _newRoleName; + private RoleInfo? _editingRole; + private string? _editingName; [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } + + [Parameter] + public bool PreviewMode { get; set; } - private async Task> LoadServerData(GridState state, CancellationToken ct) + private readonly List _previewRoles = + [ + new("Admin", "Full administrative access.", 24), + new("Editor", "Manage application content.", 8), + new("User", "Standard user permissions.", 3), + new("Viewer", "Read-only access.", 2) + ]; + + private async Task> LoadServerData( + GridState state, + CancellationToken ct) { + if (PreviewMode) + return EmptyGrid(); + var sort = state.SortDefinitions?.FirstOrDefault(); - var req = new RoleQuery + var request = new RoleQuery { PageNumber = state.Page + 1, PageSize = state.PageSize, @@ -29,109 +48,220 @@ private async Task> LoadServerData(GridState state, Descending = sort?.Descending ?? false }; - var res = await UAuthClient.Authorization.QueryRolesAsync(req); - - if (!res.IsSuccess || res.Value == null) + try { - Snackbar.Add(res.ErrorText ?? "Failed", Severity.Error); + var result = await UAuthClient.Authorization + .QueryRolesAsync(request); + + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add( + result.ErrorText ?? "Failed to load roles.", + Severity.Error); + + return EmptyGrid(); + } return new GridData { - Items = Array.Empty(), - TotalItems = 0 + Items = result.Value.Items, + TotalItems = result.Value.TotalCount }; } - - return new GridData + catch (Exception) { - Items = res.Value.Items, - TotalItems = res.Value.TotalCount - }; + Snackbar.Add("Unable to load roles.", Severity.Error); + return EmptyGrid(); + } + } + + private static GridData EmptyGrid() => new() + { + Items = Array.Empty(), + TotalItems = 0 + }; + + private async Task ReloadAsync() + { + if (PreviewMode || _grid is null || _loading) + return; + + await _grid.ReloadServerData(); + } + + private void BeginRename(RoleInfo role) + { + if (PreviewMode || _loading) + return; + + _editingRole = role; + _editingName = role.Name; } - private async Task CommittedItemChanges(RoleInfo role) + private void CancelRename() { - var req = new RenameRoleRequest + _editingRole = null; + _editingName = null; + } + + private async Task SaveRenameAsync() + { + if (PreviewMode || _loading || _editingRole is null || + string.IsNullOrWhiteSpace(_editingName)) + return; + + var request = new RenameRoleRequest { - Id = role.Id, - Name = role.Name + Id = _editingRole.Id, + Name = _editingName.Trim() }; - var result = await UAuthClient.Authorization.RenameRoleAsync(req); + _loading = true; + bool success = false; + + try + { + var result = await UAuthClient.Authorization + .RenameRoleAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to rename role.", + Severity.Error); + return; + } - if (result.IsSuccess) + success = true; + Snackbar.Add("Role renamed successfully.", Severity.Success); + CancelRename(); + } + catch (Exception) { - Snackbar.Add("Role renamed", Severity.Success); + Snackbar.Add("Unable to rename role.", Severity.Error); } - else + finally { - Snackbar.Add(result.ErrorText ?? "Rename failed", Severity.Error); + _loading = false; } - await ReloadAsync(); - return DataGridEditFormAction.Close; + if (success) + await ReloadAsync(); } - private async Task CreateRole() + private async Task CreateRoleAsync() { - if (string.IsNullOrWhiteSpace(_newRoleName)) - { - Snackbar.Add("Role name required.", Severity.Warning); + if (PreviewMode || _loading || + string.IsNullOrWhiteSpace(_newRoleName)) return; - } - var req = new CreateRoleRequest + var request = new CreateRoleRequest { - Name = _newRoleName + Name = _newRoleName.Trim() }; - var res = await UAuthClient.Authorization.CreateRoleAsync(req); + _loading = true; + bool success = false; - if (res.IsSuccess) + try { - Snackbar.Add("Role created.", Severity.Success); - await ReloadAsync(); + var result = await UAuthClient.Authorization + .CreateRoleAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to create role.", + Severity.Error); + return; + } + + success = true; + _newRoleName = null; + Snackbar.Add("Role created successfully.", Severity.Success); + } + catch (Exception) + { + Snackbar.Add("Unable to create role.", Severity.Error); } - else + finally { - Snackbar.Add(res.ErrorText ?? "Creation failed.", Severity.Error); + _loading = false; } + + if (success) + await ReloadAsync(); } - private async Task DeleteRole(RoleId roleId) + private async Task DeleteRoleAsync(RoleInfo role) { - var confirm = await DialogService.ShowMessageBoxAsync( - "Delete role", - "Are you sure?", + if (PreviewMode || _loading) + return; + + var confirmed = await DialogService.ShowMessageBoxAsync( + "Delete Role", + $"Delete the '{role.Name}' role? Existing assignments " + + "to users may also be removed.", yesText: "Delete", - cancelText: "Cancel", - options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + noText: "Cancel"); - if (confirm != true) + if (confirmed != true || _loading) return; - var req = new DeleteRoleRequest() { Id = roleId }; - var result = await UAuthClient.Authorization.DeleteRoleAsync(req); + _loading = true; + bool success = false; - if (result.IsSuccess) + try { - Snackbar.Add($"Role deleted, assignments removed from {result.Value?.RemovedAssignments.ToString() ?? "unknown"} users.", Severity.Success); - await ReloadAsync(); + var request = new DeleteRoleRequest + { + Id = role.Id + }; + + var result = await UAuthClient.Authorization + .DeleteRoleAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to delete role.", + Severity.Error); + return; + } + + success = true; + + Snackbar.Add( + $"Role deleted. Assignments removed from " + + $"{result.Value?.RemovedAssignments.ToString() ?? "unknown"} users.", + Severity.Success); + } + catch (Exception) + { + Snackbar.Add("Unable to delete role.", Severity.Error); } - else + finally { - Snackbar.Add(result.ErrorText ?? "Deletion failed.", Severity.Error); + _loading = false; } + + if (success) + await ReloadAsync(); } private async Task EditPermissions(RoleInfo role) { + if (PreviewMode || _loading) + return; + + var parameters = new DialogParameters + { + { nameof(PermissionDialog.Role), role } + }; + var dialog = await DialogService.ShowAsync( "Edit Permissions", - new DialogParameters - { - { nameof(PermissionDialog.Role), role } - }, + parameters, new DialogOptions { CloseButton = true, @@ -139,26 +269,27 @@ private async Task EditPermissions(RoleInfo role) FullWidth = true }); - var result = await dialog.Result; + await dialog.Result; await ReloadAsync(); } - private async Task ReloadAsync() + private static int GetPermissionCount(RoleInfo role) { - _loading = true; - await Task.Delay(300); - if (_grid is null) - return; + var expanded = PermissionExpander.Expand( + role.Permissions, + UAuthPermissionCatalog.GetAdminPermissions()); - await _grid.ReloadServerData(); - _loading = false; + return expanded.Count; } - private int GetPermissionCount(RoleInfo role) + private void Close() { - var expanded = PermissionExpander.Expand(role.Permissions, UAuthPermissionCatalog.GetAdminPermissions()); - return expanded.Count; + if (!_loading) + MudDialog.Close(); } - private void Cancel() => MudDialog.Cancel(); + private sealed record PreviewRole( + string Name, + string Description, + int PermissionCount); } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor.css new file mode 100644 index 00000000..75a899e2 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/RoleDialog.razor.css @@ -0,0 +1,207 @@ +.lab-role-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-role-header-icon, +.lab-role-card-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + border-radius: 11px; +} + +.lab-role-header-icon { + width: 40px; + height: 40px; +} + +.lab-role-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-role-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-role-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-role-content { + display: flex; + flex-direction: column; + gap: 19px; + min-width: 0; +} + +.lab-role-section-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + + .lab-role-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-role-section-heading span { + font-size: .73rem; + line-height: 1.5; + color: var(--mud-palette-text-secondary); + } + +.lab-role-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lab-role-grid { + overflow-x: auto; +} + +.lab-role-grid-name { + display: flex; + align-items: center; + gap: 9px; +} + + .lab-role-grid-name strong { + font-size: .8rem; + } + +.lab-role-list { + display: flex; + flex-direction: column; + gap: 11px; +} + +.lab-role-card { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + overflow: hidden; +} + +.lab-role-card-main { + display: flex; + align-items: center; + gap: 12px; + padding: 15px; +} + +.lab-role-card-icon { + width: 40px; + height: 40px; +} + +.lab-role-card-info { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 0; +} + + .lab-role-card-info strong { + font-size: .84rem; + } + + .lab-role-card-info span { + font-size: .73rem; + color: var(--mud-palette-text-secondary); + } + +.lab-role-card-actions { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 12px; + border-top: 1px solid var(--mud-palette-lines-default); + background: var(--mud-palette-background); +} + +.lab-role-create { + display: flex; + align-items: flex-start; + gap: 12px; +} + + .lab-role-create ::deep .mud-input-control { + flex: 1; + min-width: 0; + } + + .lab-role-create ::deep .mud-button-root { + flex-shrink: 0; + min-height: 40px; + } + +.lab-role-edit { + display: flex; + flex-direction: column; + gap: 13px; + padding: 15px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; +} + +.lab-role-edit-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.lab-role-hint { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-role-hint span { + font-size: .72rem; + line-height: 1.6; + } + +.lab-role-actions { + display: flex; + justify-content: flex-end; + width: 100%; +} + +@media (max-width: 600px) { + .lab-role-create { + flex-direction: column; + align-items: stretch; + } + + .lab-role-create ::deep .mud-button-root { + width: 100%; + } + + .lab-role-card-main { + flex-wrap: wrap; + padding: 12px; + } + + .lab-role-card-actions { + flex-wrap: wrap; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor index efba51c4..2a24534a 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor @@ -1,216 +1,341 @@ @using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Users.Contracts @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject IDialogService DialogService -@inject IUAuthStateManager StateManager @inject NavigationManager Nav - + - Session Management - - @if (UserKey is null) - { - User: @AuthState?.Identity?.DisplayName - } - else - { - UserKey: @UserKey.Value - } - +
+
+ +
+
+ Session Management + + Review connected devices, session activity and access. + +
+
+ - @if (_chainDetail is not null) - { - - - - Device Details +
+ @if (PreviewMode) + { + + Preview mode is active. Session actions are disabled. + + } - + @if (_chainDetail is not null) + { +
+ + All Devices + @if (!_chainDetail.IsRevoked) { - + Revoke Device } - - - - - - - Device Type - @_chainDetail.DeviceType - - - - Platform - @_chainDetail.Platform - - - - Operating System - @_chainDetail.OperatingSystem - - - - Browser - @_chainDetail.Browser - - - - Created - @_chainDetail.CreatedAt.ToLocalTime() - - - - Last Seen - @_chainDetail.LastSeenAt.ToLocalTime() - - - - State - - @_chainDetail.State - - - - - Active Session - @_chainDetail.ActiveSessionId - - - - Rotation Count - @_chainDetail.RotationCount - - - - Touch Count - @_chainDetail.TouchCount - - - - - - Session History - - - - Session Id - Created - Expires - Status - - - - @context.SessionId - @context.CreatedAt.ToLocalTime() - @context.ExpiresAt.ToLocalTime() - - @if (context.IsRevoked) - { - Revoked - } - else - { - Active - } - - - - - } - else - { - - Logout All Devices - @if (UserKey == null) +
+ +
+ Device Details + Information recorded for this authentication chain. +
+ +
+
+ Device Type + @_chainDetail.DeviceType +
+
+ Platform + @_chainDetail.Platform +
+
+ Operating System + @_chainDetail.OperatingSystem +
+
+ Browser + @_chainDetail.Browser +
+
+ Created + @_chainDetail.CreatedAt.ToLocalTime().ToString("g") +
+
+ Last Seen + @_chainDetail.LastSeenAt.ToLocalTime().ToString("g") +
+
+ State + @_chainDetail.State +
+
+ Active Session + @_chainDetail.ActiveSessionId +
+
+ Rotation Count + @_chainDetail.RotationCount +
+
+ Touch Count + @_chainDetail.TouchCount +
+
+ + + +
+ Session History + Sessions associated with this device chain. +
+ +
+ @foreach (var session in _chainDetail.Sessions) + { +
+
+ + @session.SessionId + + + + @(session.IsRevoked ? "Revoked" : "Not Revoked") + +
+
+ Created: @session.CreatedAt.ToLocalTime().ToString("g") + Expires: @session.ExpiresAt.ToLocalTime().ToString("g") +
+
+ } +
+ } + else + { +
+ Device Access + Manage active device chains and terminate access when needed. +
+ +
+ + Logout All + + + @if (UserKey is null) + { + + Logout Others + + } + + + Revoke All + + + @if (UserKey is null) + { + + Revoke Others + + } +
+ + + +
+
+ Connected Devices + Authentication chains associated with this identity. +
+ + +
+ + @if (PreviewMode) { - Logout Other Devices +
+ @foreach (var device in _previewDevices) + { +
+
+
+ +
+
+ @device.Name + @device.Platform · @device.Browser +
+ @if (device.IsCurrent) + { + + Current + + } +
+
+ Last seen: @device.LastSeen + + Details + +
+
+ } +
} - Revoke All Devices - @if (UserKey == null) + else { - Revoke Other Devices + + + + +
+ @context.Item.OperatingSystem + @context.Item.Platform · @context.Item.Browser +
+
+
+ + + + + @context.Item.State + + + + + + + @if (context.Item.IsCurrentDevice) + { + + Current + + } + else + { + — + } + + + + + + + + + + + + + + + + + + + + +
+ + + + +
} - - - - Sessions - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Id - @context.Item.ChainId - - - - Created At - @context.Item.CreatedAt - - - - Touch Count - @context.Item.TouchCount - - - - Rotation Count - @context.Item.RotationCount - - - - - - - - - } + } +
+ - Cancel +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.cs index 2c5d3934..d2793e37 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.cs @@ -10,39 +10,58 @@ namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; public partial class SessionDialog { private MudDataGrid? _grid; - private bool _loading = false; - private bool _reloadQueued; private SessionChainDetail? _chainDetail; + private bool _loading; + private bool _reloadQueued; + + private readonly List _previewDevices = + [ + new( + "Windows Desktop", + "Windows", + "Edge", + "Just now", + true, + Icons.Material.Filled.Computer), + new( + "Mobile Device", + "Android", + "Chrome", + "2 hours ago", + false, + Icons.Material.Filled.Smartphone), + new( + "MacBook", + "macOS", + "Safari", + "Yesterday", + false, + Icons.Material.Filled.LaptopMac) + ]; + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } [Parameter] public UserKey? UserKey { get; set; } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender); - - if (firstRender) - { - var result = await UAuthClient.Sessions.GetMyChainsAsync(); - if (result != null && result.IsSuccess && result.Value != null) - { - await ReloadAsync(); - StateHasChanged(); - } - } - } + [Parameter] + public bool PreviewMode { get; set; } - private async Task> LoadServerData(GridState state, CancellationToken ct) + private async Task> LoadServerData( + GridState state, + CancellationToken ct) { + if (PreviewMode) + return EmptyGrid(); + var sort = state.SortDefinitions?.FirstOrDefault(); - var req = new PageRequest + var request = new PageRequest { PageNumber = state.Page + 1, PageSize = state.PageSize, @@ -50,49 +69,53 @@ private async Task> LoadServerData(GridState> res; - - if (UserKey is null) - { - res = await UAuthClient.Sessions.GetMyChainsAsync(req); - } - else + try { - res = await UAuthClient.Sessions.GetUserChainsAsync(UserKey.Value, req); - } + UAuthResult> result = + UserKey is null + ? await UAuthClient.Sessions.GetMyChainsAsync(request) + : await UAuthClient.Sessions.GetUserChainsAsync( + UserKey.Value, request); - if (!res.IsSuccess || res.Value is null) - { - Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add( + result.ErrorText ?? "Failed to load sessions.", + Severity.Error); + return EmptyGrid(); + } return new GridData { - Items = Array.Empty(), - TotalItems = 0 + Items = result.Value.Items, + TotalItems = result.Value.TotalCount }; } - - return new GridData + catch (Exception) { - Items = res.Value.Items, - TotalItems = res.Value.TotalCount - }; + Snackbar.Add("Unable to load sessions.", Severity.Error); + return EmptyGrid(); + } } + private static GridData EmptyGrid() => new() + { + Items = Array.Empty(), + TotalItems = 0 + }; + private async Task ReloadAsync() { + if (PreviewMode || _grid is null) + return; + if (_loading) { _reloadQueued = true; return; } - if (_grid is null) - return; - _loading = true; - await InvokeAsync(StateHasChanged); - await Task.Delay(300); try { @@ -101,184 +124,269 @@ private async Task ReloadAsync() finally { _loading = false; + } - if (_reloadQueued) - { - _reloadQueued = false; - await ReloadAsync(); - } - - await InvokeAsync(StateHasChanged); + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); } } - private async Task LogoutAllAsync() + private async Task ConfirmAsync( + string title, + string message, + string confirmText) { - UAuthResult result; + if (PreviewMode || _loading) + return false; - if (UserKey is null) - { - result = await UAuthClient.Flows.LogoutAllMyDevicesAsync(); - } - else - { - result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(UserKey.Value); - } + var confirmed = await DialogService.ShowMessageBoxAsync( + title, + message, + yesText: confirmText, + noText: "Cancel"); - if (result.IsSuccess) - { - Snackbar.Add("Logged out of all devices.", Severity.Success); - if (UserKey is null) - Nav.NavigateTo("/login"); - } - else - { - Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); - } + return confirmed == true; + } + + private async Task LogoutAllAsync() + { + if (!await ConfirmAsync( + "Logout All Devices", + "All devices will be signed out, including the current device.", + "Logout All")) + return; + + await ExecuteAsync( + () => UserKey is null + ? UAuthClient.Flows.LogoutAllMyDevicesAsync() + : UAuthClient.Flows.LogoutAllUserDevicesAsync(UserKey.Value), + "All devices logged out.", + navigateOnSuccess: UserKey is null); } private async Task LogoutOthersAsync() { - var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); + if (!await ConfirmAsync( + "Logout Other Devices", + "Other devices will be signed out. Your current device will remain signed in.", + "Logout Others")) + return; - if (result.IsSuccess) - { - Snackbar.Add("Logged out of other devices.", Severity.Success); - } - else - { - Snackbar.Add(result?.ErrorText ?? "Failed to logout", Severity.Error); - } + await ExecuteAsync( + () => UAuthClient.Flows.LogoutMyOtherDevicesAsync(), + "Other devices logged out."); + } + + private async Task RevokeAllAsync() + { + if (!await ConfirmAsync( + "Revoke All Devices", + "All device chains will be revoked. This action may terminate your current session.", + "Revoke All")) + return; + + await ExecuteAsync( + () => UserKey is null + ? UAuthClient.Sessions.RevokeAllMyChainsAsync() + : UAuthClient.Sessions.RevokeAllUserChainsAsync(UserKey.Value), + "All device chains revoked.", + navigateOnSuccess: UserKey is null); + } + + private async Task RevokeOthersAsync() + { + if (!await ConfirmAsync( + "Revoke Other Devices", + "All device chains except the current one will be revoked.", + "Revoke Others")) + return; + + await ExecuteAsync( + () => UAuthClient.Sessions.RevokeMyOtherChainsAsync(), + "Other device chains revoked."); } private async Task LogoutDeviceAsync(SessionChainId chainId) { - LogoutDeviceRequest request = new() { ChainId = chainId }; - UAuthResult result; + if (!await ConfirmAsync( + "Logout Device", + "Sign out the selected device?", + "Logout")) + return; - if (UserKey is null) - { - result = await UAuthClient.Flows.LogoutMyDeviceAsync(request); - } - else - { - result = await UAuthClient.Flows.LogoutUserDeviceAsync(UserKey.Value, request); - } + var request = new LogoutDeviceRequest { ChainId = chainId }; - if (result.IsSuccess) - { - Snackbar.Add("Logged out of device.", Severity.Success); - if (result?.Value?.CurrentChain == true) - { - Nav.NavigateTo("/login"); - return; - } - await ReloadAsync(); - } - else - { - Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); - } + await ExecuteRevokeAsync( + () => UserKey is null + ? UAuthClient.Flows.LogoutMyDeviceAsync(request) + : UAuthClient.Flows.LogoutUserDeviceAsync( + UserKey.Value, request), + "Device logged out."); } - private async Task RevokeAllAsync() + private async Task RevokeChainAsync(SessionChainId chainId) { - UAuthResult result; + if (!await ConfirmAsync( + "Revoke Device", + "Revoke this device chain and invalidate its access?", + "Revoke")) + return; - if (UserKey is null) - { - result = await UAuthClient.Sessions.RevokeAllMyChainsAsync(); - } - else - { - result = await UAuthClient.Sessions.RevokeAllUserChainsAsync(UserKey.Value); - } + await ExecuteRevokeAsync( + () => UserKey is null + ? UAuthClient.Sessions.RevokeMyChainAsync(chainId) + : UAuthClient.Sessions.RevokeUserChainAsync( + UserKey.Value, chainId), + "Device revoked."); + } - if (result.IsSuccess) + private async Task ExecuteAsync( + Func> operation, + string successMessage, + bool navigateOnSuccess = false) + { + if (PreviewMode || _loading) + return; + + _loading = true; + var succeeded = false; + + try { - Snackbar.Add("Logged out of all devices.", Severity.Success); + var result = await operation(); - if (UserKey is null) - Nav.NavigateTo("/login"); + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Session operation failed.", + Severity.Error); + return; + } + + succeeded = true; + Snackbar.Add(successMessage, Severity.Success); } - else + catch (Exception) { - Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add("Session operation failed.", Severity.Error); } - } - - private async Task RevokeOthersAsync() - { - var result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync(); - if (result.IsSuccess) + finally { - Snackbar.Add("Revoked all other devices.", Severity.Success); - await ReloadAsync(); + _loading = false; } - else + + if (!succeeded) + return; + + if (navigateOnSuccess) { - Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + Nav.NavigateTo("/login"); + return; } + + await ReloadAsync(); } - private async Task RevokeChainAsync(SessionChainId chainId) + private async Task ExecuteRevokeAsync( + Func>> operation, + string successMessage) { - UAuthResult result; + if (PreviewMode || _loading) + return; - if (UserKey is null) - { - result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId); - } - else - { - result = await UAuthClient.Sessions.RevokeUserChainAsync(UserKey.Value, chainId); - } + _loading = true; + bool currentChain = false; + bool succeeded = false; - if (result.IsSuccess) + try { - Snackbar.Add("Device revoked successfully.", Severity.Success); + var result = await operation(); - if (result?.Value?.CurrentChain == true) + if (!result.IsSuccess) { - Nav.NavigateTo("/login"); + Snackbar.Add( + result.ErrorText ?? "Session operation failed.", + Severity.Error); return; } - await ReloadAsync(); + + succeeded = true; + currentChain = result.Value?.CurrentChain == true; + + Snackbar.Add(successMessage, Severity.Success); } - else + catch (Exception) { - Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + Snackbar.Add("Session operation failed.", Severity.Error); } + finally + { + _loading = false; + } + + if (!succeeded) + return; + + if (currentChain) + { + Nav.NavigateTo("/login"); + return; + } + + _chainDetail = null; + await ReloadAsync(); } private async Task ShowChainDetailsAsync(SessionChainId chainId) { - UAuthResult result; + if (PreviewMode || _loading) + return; - if (UserKey is null) - { - result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId); - } - else - { - result = await UAuthClient.Sessions.GetUserChainDetailAsync(UserKey.Value, chainId); - } + _loading = true; - if (result.IsSuccess) + try { + UAuthResult result = + UserKey is null + ? await UAuthClient.Sessions.GetMyChainDetailAsync(chainId) + : await UAuthClient.Sessions.GetUserChainDetailAsync( + UserKey.Value, chainId); + + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add( + result.ErrorText ?? "Failed to load device details.", + Severity.Error); + return; + } + _chainDetail = result.Value; } - else + catch (Exception) { - Snackbar.Add(result?.ErrorText ?? "Failed to fetch chain details.", Severity.Error); - _chainDetail = null; + Snackbar.Add("Failed to load device details.", Severity.Error); + } + finally + { + _loading = false; } } - private void ClearDetail() + private void ClearDetail() => _chainDetail = null; + + private void Cancel() { - _chainDetail = null; + if (!_loading) + MudDialog.Cancel(); } - private void Cancel() => MudDialog.Cancel(); + private sealed record PreviewDevice( + string Name, + string Platform, + string Browser, + string LastSeen, + bool IsCurrent, + string Icon); } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.css new file mode 100644 index 00000000..9173093a --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/SessionDialog.razor.css @@ -0,0 +1,243 @@ +.lab-session-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-session-header-icon, +.lab-session-device-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + border-radius: 11px; +} + +.lab-session-header-icon { + width: 40px; + height: 40px; +} + +.lab-session-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-session-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-session-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-session-content { + display: flex; + flex-direction: column; + gap: 19px; + min-width: 0; +} + +.lab-session-section-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + + .lab-session-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-session-section-heading span { + color: var(--mud-palette-text-secondary); + font-size: .73rem; + line-height: 1.5; + } + +.lab-session-bulk-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.lab-session-list-heading, +.lab-session-detail-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lab-session-grid { + overflow-x: auto; +} + +.lab-session-grid-device { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 130px; +} + + .lab-session-grid-device strong { + font-size: .8rem; + } + + .lab-session-grid-device span { + font-size: .72rem; + color: var(--mud-palette-text-secondary); + } + +.lab-session-device-list { + display: flex; + flex-direction: column; + gap: 11px; +} + +.lab-session-device-card { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + overflow: hidden; +} + +.lab-session-device-top { + display: flex; + align-items: center; + gap: 12px; + padding: 15px; +} + +.lab-session-device-icon { + width: 42px; + height: 42px; +} + +.lab-session-device-info { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 0; +} + + .lab-session-device-info strong { + font-size: .84rem; + } + + .lab-session-device-info span { + font-size: .73rem; + color: var(--mud-palette-text-secondary); + } + +.lab-session-device-bottom { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 14px; + background: var(--mud-palette-background); + border-top: 1px solid var(--mud-palette-lines-default); +} + + .lab-session-device-bottom span { + font-size: .72rem; + color: var(--mud-palette-text-secondary); + } + +.lab-session-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.lab-session-detail-item { + display: flex; + flex-direction: column; + gap: 7px; + padding: 13px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 10px; + min-width: 0; +} + + .lab-session-detail-item span { + font-size: .72rem; + color: var(--mud-palette-text-secondary); + } + + .lab-session-detail-item strong { + font-size: .79rem; + font-weight: 650; + } + +.lab-session-break { + overflow-wrap: anywhere; + word-break: break-word; +} + +.lab-session-history { + display: flex; + flex-direction: column; + gap: 10px; +} + +.lab-session-history-item { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 10px; +} + +.lab-session-history-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + + .lab-session-history-top > span { + font-size: .78rem; + } + +.lab-session-history-dates { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + color: var(--mud-palette-text-secondary); + font-size: .72rem; +} + +.lab-session-footer { + display: flex; + justify-content: flex-end; + width: 100%; +} + +@media (max-width: 600px) { + .lab-session-detail-grid { + grid-template-columns: 1fr; + } + + .lab-session-device-top { + padding: 12px; + flex-wrap: wrap; + } + + .lab-session-bulk-actions ::deep .mud-button-root { + flex: 1 1 calc(50% - 8px); + } + + .lab-session-detail-toolbar { + flex-wrap: wrap; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor index 97631deb..25be39b8 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor @@ -3,71 +3,190 @@ @inject IDialogService DialogService @inject ISnackbar Snackbar - + - User Management - @_user?.UserKey.Value +
+
+ +
+
+ User Management + + Review identity details and manage account access. + +
+
- - - - - Display Name - @_user?.DisplayName - - - - Username - @_user?.UserName - - - - Email - @_user?.PrimaryEmail - - - - Phone - @_user?.PrimaryPhone - - - - Created - @_user?.CreatedAt?.ToLocalTime() - - - - Status - @_user?.Status - - - @foreach (var s in Enum.GetValues()) - { - @s - } - - Change - - - - - - - - Management - - Sessions - Profile - Identifiers - Credentials - Roles - - +
+ @if (PreviewMode) + { + + Preview mode is active. User management actions are disabled. + + } + + @if (_isLoading) + { + + } + else if (_loadError is not null) + { + + @_loadError + + } + else if (_user is not null || PreviewMode) + { +
+
+ +
+ +
+ @DisplayName + @UserName +
+ + + @CurrentStatus + +
+ +
+ Identity Information + Primary account information and registration details. +
+ +
+
+ Display Name + @DisplayName +
+ +
+ Username + @UserName +
+ +
+ Email + @PrimaryEmail +
+ +
+ Phone + @PrimaryPhone +
+ +
+ Created + @CreatedAt +
+ +
+ User Key + @UserKeyText +
+
+ + + +
+ Account Status + Control whether this identity can access the application. +
+ +
+ + @foreach (var status in Enum.GetValues()) + { + + @status + + } + + + + Update Status + +
+ + + +
+ Management + Manage the user's authentication and authorization settings. +
+ +
+ + Profile + + + + Identifiers + + + + Credentials + + + + Sessions + + + + Roles + +
+ } +
- Close +
+ + Close + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.cs index 9f4baa1a..5d033e09 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.cs @@ -13,76 +13,201 @@ public partial class UserDetailDialog private UserView? _user; private AdminAssignableUserStatus _status; + private bool _isLoading = true; + private bool _isSubmitting; + private string? _loadError; + [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } [Parameter] public UserKey UserKey { get; set; } + [Parameter] + public bool PreviewMode { get; set; } + + private string DisplayName => + PreviewMode ? "Alex Morgan" : _user?.DisplayName ?? "—"; + + private string UserName => + PreviewMode ? "alex.morgan" : _user?.UserName ?? "—"; + + private string PrimaryEmail => + PreviewMode ? "alex@example.com" : _user?.PrimaryEmail ?? "—"; + + private string PrimaryPhone => + PreviewMode ? "+1 555 010 1234" : _user?.PrimaryPhone ?? "—"; + + private string CreatedAt => + PreviewMode + ? "Oct 12, 2026" + : _user?.CreatedAt?.ToLocalTime().ToString("g") ?? "—"; + + private string UserKeyText => + PreviewMode ? "preview-user-001" : _user?.UserKey.Value ?? "—"; + + private UserStatus? CurrentStatus => + PreviewMode ? UserStatus.Active : _user?.Status; + + private bool HasStatusChange => + _user is not null && + _status != _user.Status.ToAdminAssignableUserStatus(); + protected override async Task OnInitializedAsync() { - await base.OnInitializedAsync(); - var result = await UAuthClient.Users.GetUserAsync(UserKey); + if (PreviewMode) + { + _status = AdminAssignableUserStatus.Active; + _isLoading = false; + return; + } - if (result.IsSuccess) + await LoadUserAsync(); + } + + private async Task LoadUserAsync() + { + _isLoading = true; + _loadError = null; + + try { + var result = await UAuthClient.Users.GetUserAsync(UserKey); + + if (!result.IsSuccess || result.Value is null) + { + _loadError = result.ErrorText + ?? "Unable to load user details."; + return; + } + _user = result.Value; - _status = _user?.Status.ToAdminAssignableUserStatus() ?? AdminAssignableUserStatus.Unknown; + _status = _user.Status.ToAdminAssignableUserStatus(); + } + catch (Exception) + { + _loadError = "An unexpected error occurred while loading the user."; + } + finally + { + _isLoading = false; } } - private async Task OpenSessions() + private async Task ChangeStatusAsync() { - await DialogService.ShowAsync("Session Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + if (PreviewMode || _isSubmitting || _user is null || + !HasStatusChange) + return; + + _isSubmitting = true; + + try + { + var request = new ChangeUserStatusAdminRequest + { + NewStatus = _status + }; + + var result = await UAuthClient.Users + .ChangeUserStatusAsync(_user.UserKey, request); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to update user status.", + Severity.Error); + return; + } + + _user = _user with + { + Status = _status.ToUserStatus() + }; + + Snackbar.Add( + "User status updated successfully.", + Severity.Success); + } + catch (Exception) + { + Snackbar.Add("Unable to update user status.", Severity.Error); + } + finally + { + _isSubmitting = false; + } + } + + private DialogParameters CreateChildParameters() + { + var parameters = new DialogParameters + { + { "PreviewMode", PreviewMode } + }; + + if (!PreviewMode) + { + parameters.Add("AuthState", AuthState); + parameters.Add("UserKey", _user!.UserKey); + } + + return parameters; } - private async Task OpenProfile() + private async Task OpenSessionsAsync() { - await DialogService.ShowAsync("Profile Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + await OpenChildAsync( + "Session Management", MaxWidth.Large); } - private async Task OpenIdentifiers() + private async Task OpenProfileAsync() { - await DialogService.ShowAsync("Identifier Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + await OpenChildAsync( + "Profile Management", MaxWidth.Medium); } - private async Task OpenCredentials() + private async Task OpenIdentifiersAsync() { - await DialogService.ShowAsync("Credentials", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + await OpenChildAsync( + "Identifier Management", MaxWidth.Medium); } - private async Task OpenRoles() + private async Task OpenCredentialsAsync() { - await DialogService.ShowAsync("Roles", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + await OpenChildAsync( + "Credential Management", MaxWidth.Small); } - private async Task ChangeStatusAsync() + private async Task OpenRolesAsync() + { + await OpenChildAsync( + "User Roles", MaxWidth.Small); + } + + private async Task OpenChildAsync( + string title, + MaxWidth maxWidth) + where TDialog : IComponent { - if (_user is null) + if (_isLoading || _isSubmitting) return; - ChangeUserStatusAdminRequest request = new() - { - NewStatus = _status - }; + var dialog = await DialogService.ShowAsync( + title, + CreateChildParameters(), + UAuthDialog.GetDialogOptions(maxWidth)); - var result = await UAuthClient.Users.ChangeUserStatusAsync(_user.UserKey, request); + await dialog.Result; - if (result.IsSuccess) - { - Snackbar.Add("User status updated", Severity.Success); - _user = _user with { Status = _status.ToUserStatus() }; - } - else - { - Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); - } + if (!PreviewMode) + await LoadUserAsync(); } - private Color GetStatusColor(UserStatus? status) + private static Color GetStatusColor(UserStatus? status) { return status switch { @@ -95,6 +220,7 @@ private Color GetStatusColor(UserStatus? status) private void Close() { - MudDialog.Close(); + if (!_isSubmitting) + MudDialog.Close(); } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.css new file mode 100644 index 00000000..adc07a85 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserDetailDialog.razor.css @@ -0,0 +1,188 @@ +.lab-user-detail-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-user-detail-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-user-detail-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-user-detail-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-user-detail-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-user-detail-content { + display: flex; + flex-direction: column; + gap: 19px; + min-width: 0; +} + +.lab-user-detail-identity { + display: flex; + align-items: center; + gap: 13px; + padding: 16px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + background: var(--mud-palette-background); +} + +.lab-user-detail-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 13px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-user-detail-identity-info { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1; + min-width: 0; +} + + .lab-user-detail-identity-info strong { + font-size: .95rem; + font-weight: 750; + } + + .lab-user-detail-identity-info span { + font-size: .75rem; + color: var(--mud-palette-text-secondary); + } + +.lab-user-detail-section-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + + .lab-user-detail-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-user-detail-section-heading span { + font-size: .73rem; + line-height: 1.5; + color: var(--mud-palette-text-secondary); + } + +.lab-user-detail-info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.lab-user-detail-info-item { + display: flex; + flex-direction: column; + gap: 7px; + padding: 13px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 10px; + min-width: 0; +} + + .lab-user-detail-info-item span { + font-size: .72rem; + color: var(--mud-palette-text-secondary); + } + + .lab-user-detail-info-item strong { + font-size: .79rem; + font-weight: 650; + } + +.lab-user-detail-break { + overflow-wrap: anywhere; + word-break: break-word; +} + +.lab-user-detail-status { + display: flex; + align-items: flex-start; + gap: 12px; +} + + .lab-user-detail-status ::deep .mud-input-control { + flex: 1; + min-width: 0; + } + + .lab-user-detail-status ::deep .mud-button-root { + flex-shrink: 0; + min-height: 40px; + } + +.lab-user-detail-management { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + + .lab-user-detail-management ::deep .mud-button-root { + justify-content: flex-start; + min-height: 45px; + text-transform: none; + } + +.lab-user-detail-actions { + display: flex; + justify-content: flex-end; + width: 100%; +} + +@media (max-width: 600px) { + .lab-user-detail-info-grid { + grid-template-columns: 1fr; + } + + .lab-user-detail-status { + flex-direction: column; + align-items: stretch; + } + + .lab-user-detail-status ::deep .mud-button-root { + width: 100%; + } + + .lab-user-detail-management { + grid-template-columns: 1fr; + } + + .lab-user-detail-identity { + flex-wrap: wrap; + padding: 13px; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor index 6e754848..dce6794e 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor @@ -3,47 +3,153 @@ @inject IDialogService DialogService @inject ISnackbar Snackbar - - + - User Roles - UserKey: @UserKey.Value +
+
+ +
+
+ Manage User Roles + + Assign and remove authorization roles for this identity. + +
+
- - Assigned Roles +
+ @if (PreviewMode) + { + + Preview mode is active. Role assignments cannot be changed. + + } - @if (_roles.Count == 0) + @if (_isLoading) { - No roles assigned + } + else if (_loadError is not null) + { + + @_loadError + + } + else + { +
+
+ +
+ Assigned Roles + Roles currently granted to this identity. +
+
- - @foreach (var role in _roles) - { - @role - } - + @if (_roles.Count == 0) + { +
+ + No roles assigned to this user. +
+ } + else + { +
+ @foreach (var role in _roles) + { + + @role + + } +
+ } +
- + - Add Role - +
+
+ +
+ Assign New Role + Select an available role to grant additional permissions. +
+
- - @foreach (var role in _allRoles) +
+ + @foreach (var role in AvailableRoles) + { + + @role.Name + + } + + + + Assign Role + +
+ + @if (AvailableRoles.Count == 0) { - @role.Name + + No additional roles are currently available. + } -
+
- Add -
- +
+ + + Role assignments affect authorization decisions. + Changes may require a new authentication or authorization + evaluation to be reflected in active sessions. + +
+ } +
- Close +
+ + Close + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor.cs index 47a2c4ae..eae193cd 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor.cs @@ -12,113 +12,216 @@ public partial class UserRoleDialog private IMudDialogInstance MudDialog { get; set; } = default!; [Parameter] - public UAuthState AuthState { get; set; } = default!; + public UAuthState? AuthState { get; set; } [Parameter] public UserKey UserKey { get; set; } = default!; - private List _roles = new(); - private List _allRoles = new(); + [Parameter] + public bool PreviewMode { get; set; } + + private List _roles = []; + private List _allRoles = []; private string? _selectedRole; + private bool _isLoading = true; + private bool _isSubmitting; + private string? _loadError; + + private List AvailableRoles => + _allRoles + .Where(role => !_roles.Contains( + role.Name, + StringComparer.OrdinalIgnoreCase)) + .ToList(); protected override async Task OnInitializedAsync() { - await LoadRoles(); + if (PreviewMode) + { + LoadPreviewData(); + _isLoading = false; + return; + } + + await LoadRolesAsync(); } - private async Task LoadRoles() + private void LoadPreviewData() { - var userRoles = await UAuthClient.Authorization.GetUserRolesAsync(UserKey); + _roles = ["User", "Editor"]; + _allRoles = + [ + new RoleInfo { Name = "User" }, + new RoleInfo { Name = "Editor" }, + new RoleInfo { Name = "Admin" }, + new RoleInfo { Name = "Viewer" } + ]; + } - if (userRoles.IsSuccess && userRoles.Value != null) - _roles = userRoles.Value.Roles.Items.Select(x => x.Name).ToList(); + private async Task LoadRolesAsync() + { + _isLoading = true; + _loadError = null; - var roles = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery + try { - PageNumber = 1, - PageSize = 200 - }); + var userRoles = await UAuthClient.Authorization + .GetUserRolesAsync(UserKey); + + if (!userRoles.IsSuccess || userRoles.Value is null) + { + _loadError = userRoles.ErrorText + ?? "Unable to load assigned roles."; + return; + } + + var roles = await UAuthClient.Authorization + .QueryRolesAsync(new RoleQuery + { + PageNumber = 1, + PageSize = 200 + }); + + if (!roles.IsSuccess || roles.Value is null) + { + _loadError = roles.ErrorText + ?? "Unable to load available roles."; + return; + } + + _roles = userRoles.Value.Roles.Items + .Select(x => x.Name) + .ToList(); - if (roles.IsSuccess && roles.Value != null) _allRoles = roles.Value.Items.ToList(); + } + catch (Exception) + { + _loadError = "An unexpected error occurred while loading roles."; + } + finally + { + _isLoading = false; + } } - private async Task AddRole() + private async Task AddRoleAsync() { - if (string.IsNullOrWhiteSpace(_selectedRole)) + if (PreviewMode || _isSubmitting || + string.IsNullOrWhiteSpace(_selectedRole)) + return; + + var roleName = _selectedRole; + + if (_roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)) return; var request = new AssignRoleRequest { UserKey = UserKey, - RoleName = _selectedRole + RoleName = roleName }; - var result = await UAuthClient.Authorization.AssignRoleToUserAsync(request); + _isSubmitting = true; - if (result.IsSuccess) + try { - _roles.Add(_selectedRole); - Snackbar.Add("Role assigned", Severity.Success); + var result = await UAuthClient.Authorization + .AssignRoleToUserAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to assign role.", + Severity.Error); + return; + } + + _roles.Add(roleName); + _selectedRole = null; + + Snackbar.Add("Role assigned successfully.", Severity.Success); } - else + catch (Exception) { - Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + Snackbar.Add("An unexpected error occurred.", Severity.Error); + } + finally + { + _isSubmitting = false; } - - _selectedRole = null; } - private async Task RemoveRole(string role) + private async Task RemoveRoleAsync(string role) { - var confirm = await DialogService.ShowMessageBoxAsync( + if (PreviewMode || _isSubmitting) + return; + + var confirmed = await DialogService.ShowMessageBoxAsync( "Remove Role", - $"Remove {role} from user?", + $"Remove the '{role}' role from this user?", yesText: "Remove", - noText: "Cancel", - options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + noText: "Cancel"); - if (confirm != true) - { - Snackbar.Add("Role remove process cancelled.", Severity.Info); + if (confirmed != true) return; - } - if (role == "Admin") + if (IsAdminRole(role)) { - var confirm2 = await DialogService.ShowMessageBoxAsync( - "Are You Sure", - "You are going to remove admin role. This action may cause the application unuseable.", - yesText: "Remove", - noText: "Cancel", - options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); - - if (confirm2 != true) - { - Snackbar.Add("Role remove process cancelled.", Severity.Info); + var adminConfirmed = await DialogService.ShowMessageBoxAsync( + "Remove Administrator Role", + "Removing the Admin role may prevent this user from " + + "managing the application. Continue?", + yesText: "Remove Admin", + noText: "Cancel"); + + if (adminConfirmed != true) return; - } } - var request = new RemoveRoleRequest + _isSubmitting = true; + + try { - UserKey = UserKey, - RoleName = role - }; + var request = new RemoveRoleRequest + { + UserKey = UserKey, + RoleName = role + }; - var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(request); + var result = await UAuthClient.Authorization + .RemoveRoleFromUserAsync(request); - if (result.IsSuccess) + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to remove role.", + Severity.Error); + return; + } + + _roles.RemoveAll(x => + string.Equals(x, role, StringComparison.OrdinalIgnoreCase)); + + Snackbar.Add("Role removed successfully.", Severity.Success); + } + catch (Exception) { - _roles.Remove(role); - Snackbar.Add("Role removed.", Severity.Success); + Snackbar.Add("An unexpected error occurred.", Severity.Error); } - else + finally { - Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + _isSubmitting = false; } } - private void Close() => MudDialog.Close(); + private static bool IsAdminRole(string role) => + string.Equals(role, "Admin", StringComparison.OrdinalIgnoreCase); + + private void Close() + { + if (!_isSubmitting) + MudDialog.Close(); + } } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor.css new file mode 100644 index 00000000..0a39e7da --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UserRoleDialog.razor.css @@ -0,0 +1,141 @@ +.lab-user-role-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-user-role-header-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 11px; + background: var(--mud-palette-primary-hover); + color: var(--mud-palette-primary); +} + +.lab-user-role-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-user-role-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-user-role-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-user-role-content { + display: flex; + flex-direction: column; + gap: 21px; + padding: 4px 0; +} + +.lab-user-role-section { + display: flex; + flex-direction: column; + gap: 17px; +} + +.lab-user-role-section-heading { + display: flex; + align-items: flex-start; + gap: 12px; +} + + .lab-user-role-section-heading > div { + display: flex; + flex-direction: column; + gap: 4px; + } + + .lab-user-role-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-user-role-section-heading span { + font-size: .73rem; + line-height: 1.5; + color: var(--mud-palette-text-secondary); + } + +.lab-user-role-chips { + display: flex; + flex-wrap: wrap; + gap: 9px; +} + +.lab-user-role-empty { + display: flex; + align-items: center; + gap: 10px; + padding: 17px; + border: 1px dashed var(--mud-palette-lines-default); + border-radius: 11px; + color: var(--mud-palette-text-secondary); + font-size: .78rem; +} + +.lab-user-role-add { + display: flex; + align-items: flex-start; + gap: 12px; +} + + .lab-user-role-add ::deep .mud-input-control { + flex: 1; + min-width: 0; + } + + .lab-user-role-add ::deep .mud-button-root { + flex-shrink: 0; + min-height: 40px; + } + +.lab-user-role-hint { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-user-role-hint span { + font-size: .72rem; + line-height: 1.6; + } + +.lab-user-role-actions { + display: flex; + justify-content: flex-end; + width: 100%; +} + +@media (max-width: 600px) { + .lab-user-role-add { + flex-direction: column; + align-items: stretch; + } + + .lab-user-role-add ::deep .mud-button-root { + width: 100%; + } + + .lab-user-role-content { + gap: 18px; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor index 66c8e887..0c221d1e 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor @@ -4,81 +4,231 @@ @inject IDialogService DialogService @inject ISnackbar Snackbar - + - User Management - Browse, create and manage users +
+
+ +
+
+ User Management + + Browse identities, manage access and create users. + +
+
- - - - - - - - - - - - - - Users - - New User - - - - - - - - - - - @context.Item.Status - - - - - - - - - - - - - - - - - - - - - Id - @context.Item.UserKey.Value - - - - Created At - @context.Item.CreatedAt - - - - - - - - - +
+ @if (PreviewMode) + { + + Preview mode is active. User records are examples + and cannot be modified. + + } + +
+
+ Users + Search and manage registered identities. +
+ + + New User + +
+ +
+ + + + + All Statuses + + @foreach (var status in Enum.GetValues()) + { + + @status + + } + +
+ + @if (PreviewMode) + { +
+ @foreach (var user in FilteredPreviewUsers) + { +
+
+
+ +
+ +
+ @user.DisplayName + @user.UserName + @user.Email +
+ + + @user.Status + +
+ +
+ + Created @user.CreatedAt + + + + + + Manage + + + +
+
+ } + + @if (FilteredPreviewUsers.Count == 0) + { +
+ + No users found + Try another search or status filter. +
+ } +
+ } + else + { + + + + +
+ @context.Item.DisplayName + @context.Item.UserName +
+
+
+ + + + + + + @context.Item.Status + + + + + + + + + + + + + + + + + +
+ + + + +
+ } + +
+ + + User management actions are subject to server-side + authorization. Deleting a user uses soft deletion. + +
+
- Close +
+ + Close + +
diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor.cs index e945d65a..2cfb447f 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor.cs +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor.cs @@ -11,22 +11,79 @@ namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; public partial class UsersDialog { private MudDataGrid? _grid; + private bool _loading; - private string? _search; private bool _reloadQueued; + private string? _search; private UserStatus? _statusFilter; [CascadingParameter] - IMudDialogInstance MudDialog { get; set; } = default!; + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState? AuthState { get; set; } [Parameter] - public UAuthState AuthState { get; set; } = default!; + public bool PreviewMode { get; set; } - private async Task> LoadUsers(GridState state, CancellationToken ct) + private readonly List _previewUsers = + [ + new( + "preview-001", + "Alex Morgan", + "alex.morgan", + "alex@example.com", + UserStatus.Active, + "Oct 12, 2026"), + new( + "preview-002", + "Taylor Reed", + "taylor.reed", + "taylor@example.com", + UserStatus.Active, + "Oct 14, 2026"), + new( + "preview-003", + "Jordan Lee", + "jordan.lee", + "jordan@example.com", + UserStatus.Suspended, + "Oct 15, 2026"), + new( + "preview-004", + "Casey Parker", + "casey.parker", + "casey@example.com", + UserStatus.Disabled, + "Oct 16, 2026") + ]; + + private List FilteredPreviewUsers => + _previewUsers + .Where(user => + (!_statusFilter.HasValue || + user.Status == _statusFilter.Value) && + (string.IsNullOrWhiteSpace(_search) || + user.DisplayName.Contains( + _search, StringComparison.OrdinalIgnoreCase) || + user.UserName.Contains( + _search, StringComparison.OrdinalIgnoreCase) || + user.Email.Contains( + _search, StringComparison.OrdinalIgnoreCase) || + user.Key.Contains( + _search, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + private async Task> LoadUsers( + GridState state, + CancellationToken ct) { + if (PreviewMode) + return EmptyGrid(); + var sort = state.SortDefinitions?.FirstOrDefault(); - var req = new UserQuery + var request = new UserQuery { PageNumber = state.Page + 1, PageSize = state.PageSize, @@ -36,39 +93,50 @@ private async Task> LoadUsers(GridState state Descending = sort?.Descending ?? false }; - var res = await UAuthClient.Users.QueryAsync(req); - - if (!res.IsSuccess || res.Value == null) + try { - Snackbar.Add(res.ErrorText ?? "Failed to load users.", Severity.Error); + var result = await UAuthClient.Users.QueryAsync(request); + + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add( + result.ErrorText ?? "Failed to load users.", + Severity.Error); + + return EmptyGrid(); + } return new GridData { - Items = Array.Empty(), - TotalItems = 0 + Items = result.Value.Items, + TotalItems = result.Value.TotalCount }; } - - return new GridData + catch (Exception) { - Items = res.Value.Items, - TotalItems = res.Value.TotalCount - }; + Snackbar.Add("Unable to load users.", Severity.Error); + return EmptyGrid(); + } } + private static GridData EmptyGrid() => new() + { + Items = Array.Empty(), + TotalItems = 0 + }; + private async Task ReloadAsync() { + if (PreviewMode || _grid is null) + return; + if (_loading) { _reloadQueued = true; return; } - if (_grid is null) - return; - _loading = true; - await InvokeAsync(StateHasChanged); try { @@ -77,84 +145,137 @@ private async Task ReloadAsync() finally { _loading = false; + } - if (_reloadQueued) - { - _reloadQueued = false; - await ReloadAsync(); - } - - await InvokeAsync(StateHasChanged); + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); } } - private async Task OnStatusChanged(UserStatus? status) + private async Task OnSearchChanged(string? value) { - _statusFilter = status; - await ReloadAsync(); + _search = value; + + if (!PreviewMode) + await ReloadAsync(); } - private async Task OpenUser(UserKey userKey) + private async Task OnStatusChanged(UserStatus? value) { - var dialog = await DialogService.ShowAsync("User", UAuthDialog.GetDialogParameters(AuthState, userKey), UAuthDialog.GetDialogOptions()); + _statusFilter = value; + + if (!PreviewMode) + await ReloadAsync(); + } + + private async Task OpenPreviewUserAsync() + { + var parameters = new DialogParameters + { + { x => x.PreviewMode, true } + }; + + await DialogService.ShowAsync( + "User Management", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Medium)); + } + + private async Task OpenUserAsync(UserKey userKey) + { + if (PreviewMode || _loading) + return; + + var parameters = new DialogParameters + { + { x => x.UserKey, userKey }, + { x => x.AuthState, AuthState } + }; + + var dialog = await DialogService.ShowAsync( + "User Management", + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Medium)); + await dialog.Result; await ReloadAsync(); } - private async Task OpenCreateUser() + private async Task OpenCreateUserAsync() { + if (_loading) + return; + + var parameters = new DialogParameters + { + { x => x.PreviewMode, PreviewMode } + }; + var dialog = await DialogService.ShowAsync( "Create User", - new DialogOptions - { - MaxWidth = MaxWidth.Small, - FullWidth = true, - CloseButton = true - }); + parameters, + UAuthDialog.GetDialogOptions(MaxWidth.Small)); var result = await dialog.Result; - if (result?.Canceled == false) + if (!PreviewMode && result?.Canceled == false) await ReloadAsync(); } private async Task DeleteUserAsync(UserSummary user) { - var confirm = await DialogService.ShowMessageBoxAsync( - title: "Delete user", - markupMessage: (MarkupString)$""" - Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.PrimaryEmail ?? user.UserKey}? -

- This operation is intended for admin usage. - """, + if (PreviewMode || _loading) + return; + + var confirmed = await DialogService.ShowMessageBoxAsync( + "Delete User", + $"Delete '{user.DisplayName ?? user.UserName ?? user.PrimaryEmail}'? " + + "This operation uses soft deletion.", yesText: "Delete", - cancelText: "Cancel", - options: new DialogOptions - { - MaxWidth = MaxWidth.Medium, - FullWidth = true, - BackgroundClass = "uauth-blur-slight" - }); + noText: "Cancel"); - if (confirm != true) + if (confirmed != true || _loading) return; - var req = new DeleteUserRequest + _loading = true; + bool success = false; + + try { - Mode = DeleteMode.Soft - }; + var request = new DeleteUserRequest + { + Mode = DeleteMode.Soft + }; + + var result = await UAuthClient.Users + .DeleteUserAsync(user.UserKey, request); - var result = await UAuthClient.Users.DeleteUserAsync(user.UserKey, req); + if (!result.IsSuccess) + { + Snackbar.Add( + result.ErrorText ?? "Failed to delete user.", + Severity.Error); + return; + } - if (result.IsSuccess) + success = true; + Snackbar.Add( + "User deleted successfully.", + Severity.Success); + } + catch (Exception) { - Snackbar.Add("User deleted successfully.", Severity.Success); - await ReloadAsync(); + Snackbar.Add("Unable to delete user.", Severity.Error); } - else + finally { - Snackbar.Add(result.ErrorText ?? "Failed to delete user.", Severity.Error); + _loading = false; } + + if (success) + await ReloadAsync(); } private static Color GetStatusColor(UserStatus status) @@ -171,6 +292,15 @@ private static Color GetStatusColor(UserStatus status) private void Close() { - MudDialog.Close(); + if (!_loading) + MudDialog.Close(); } + + private sealed record PreviewUser( + string Key, + string DisplayName, + string UserName, + string Email, + UserStatus Status, + string CreatedAt); } diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor.css new file mode 100644 index 00000000..24df772f --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Dialogs/UsersDialog.razor.css @@ -0,0 +1,206 @@ +.lab-users-header { + display: flex; + align-items: center; + gap: 12px; +} + +.lab-users-header-icon, +.lab-users-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + border-radius: 11px; +} + +.lab-users-header-icon { + width: 40px; + height: 40px; +} + +.lab-users-header-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + + .lab-users-header-text ::deep .mud-typography-h6 { + font-size: 1rem; + font-weight: 750; + line-height: 1.3; + } + + .lab-users-header-text ::deep .mud-typography-body2 { + font-size: .73rem; + line-height: 1.4; + } + +.lab-users-content { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} + +.lab-users-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lab-users-section-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + + .lab-users-section-heading strong { + font-size: .85rem; + font-weight: 750; + } + + .lab-users-section-heading span { + font-size: .73rem; + color: var(--mud-palette-text-secondary); + } + +.lab-users-filters { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(160px, 1fr); + gap: 12px; +} + +.lab-users-grid { + overflow-x: auto; +} + +.lab-users-grid-identity { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 120px; +} + + .lab-users-grid-identity strong { + font-size: .8rem; + } + + .lab-users-grid-identity span { + font-size: .72rem; + color: var(--mud-palette-text-secondary); + } + +.lab-users-list { + display: flex; + flex-direction: column; + gap: 11px; +} + +.lab-users-card { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 12px; + overflow: hidden; +} + +.lab-users-card-main { + display: flex; + align-items: center; + gap: 12px; + padding: 15px; +} + +.lab-users-avatar { + width: 43px; + height: 43px; +} + +.lab-users-card-info { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + + .lab-users-card-info strong { + font-size: .85rem; + } + + .lab-users-card-info span { + font-size: .73rem; + color: var(--mud-palette-text-secondary); + overflow-wrap: anywhere; + } + +.lab-users-card-actions { + display: flex; + align-items: center; + gap: 9px; + padding: 9px 13px; + border-top: 1px solid var(--mud-palette-lines-default); + background: var(--mud-palette-background); +} + +.lab-users-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 9px; + padding: 30px 15px; + text-align: center; + color: var(--mud-palette-text-secondary); +} + + .lab-users-empty strong { + font-size: .85rem; + } + + .lab-users-empty span { + font-size: .73rem; + } + +.lab-users-hint { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px 14px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-background); + color: var(--mud-palette-text-secondary); +} + + .lab-users-hint span { + font-size: .72rem; + line-height: 1.6; + } + +.lab-users-actions { + display: flex; + justify-content: flex-end; + width: 100%; +} + +@media (max-width: 600px) { + .lab-users-toolbar { + align-items: flex-start; + flex-wrap: wrap; + } + + .lab-users-filters { + grid-template-columns: 1fr; + } + + .lab-users-card-main { + flex-wrap: wrap; + padding: 12px; + } + + .lab-users-card-actions { + flex-wrap: wrap; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleHome.razor b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleHome.razor index ec51e981..91e76f3d 100644 --- a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleHome.razor +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleHome.razor @@ -49,112 +49,193 @@ - - - - Session - - - - - - - Validate - - - - - - Manual Refresh - - - - - - Logout - - - - - - Account - - - - - Manage Sessions - - - - Manage Profile - - - - Manage Identifiers - - - - Manage Credentials - - - - Suspend | Delete Account - - - - Admin - - - - - - - - - @if (_showAdminPreview) - { - - Admin operations are shown for preview. Sign in as an Admin to execute them. - - } - - @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) - { - - - - @* *@ - @* *@ - User Management - @* *@ - - - - - - @* *@ - Role Management - @* *@ - - - - } - - - Resource Api - - + + +
+ +
+
+ + Session +
+ +
+ + + + + +
+
+ +
+
+ + Account +
+ +
+ + + + + + + + + +
+
+ +
+
+ + Administration + + + + + + +
+ + @if (_showAdminPreview) + { + + Admin operations are shown for preview. + Sign in as an Admin to execute them. + + } - - Manage Resource - - + @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) + { +
+ + + +
+ } +
+ +
+
+ + Resource API +
+ +
+ +
+
+ +
- + diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleHome.razor.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleHome.razor.css new file mode 100644 index 00000000..301860f3 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Components/Page/SampleHome.razor.css @@ -0,0 +1,136 @@ +.uauth-operations { + display: flex; + flex-direction: column; + gap: 22px; + padding: 8px 0; +} + +.uauth-operation-section { + display: flex; + flex-direction: column; + gap: 11px; + min-width: 0; +} + + .uauth-operation-section + .uauth-operation-section { + padding-top: 18px; + border-top: 1px solid var(--mud-palette-lines-default); + } + +.uauth-operation-heading { + display: flex; + align-items: center; + gap: 8px; + color: var(--mud-palette-text-secondary); +} + + .uauth-operation-heading > span { + font-size: .76rem; + font-weight: 750; + letter-spacing: .02em; + } + +.uauth-operation-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; +} + +.uauth-operation { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + min-height: 65px; + padding: 12px; + border: 1px solid var(--mud-palette-lines-default); + border-radius: 11px; + background: var(--mud-palette-surface); + color: var(--mud-palette-text-primary); + text-align: left; + font: inherit; + cursor: pointer; + transition: border-color .16s ease, background-color .16s ease, transform .16s ease; +} + + .uauth-operation:hover { + border-color: var(--mud-palette-primary); + background: var(--mud-palette-primary-hover); + transform: translateY(-1px); + } + + .uauth-operation:focus-visible { + outline: 2px solid var(--mud-palette-primary); + outline-offset: 2px; + } + + .uauth-operation:active { + transform: translateY(0); + } + + .uauth-operation > .mud-icon-root { + flex-shrink: 0; + } + +.uauth-operation-text { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + + .uauth-operation-text strong { + font-size: .79rem; + font-weight: 700; + line-height: 1.3; + overflow-wrap: anywhere; + } + + .uauth-operation-text small { + font-size: .67rem; + line-height: 1.4; + color: var(--mud-palette-text-secondary); + overflow-wrap: anywhere; + } + +.uauth-operation-danger:hover { + border-color: var(--mud-palette-error); + background: var(--mud-palette-error-hover); +} + +.uauth-profile-card { + height: 90vh; + max-height: 90vh; + overflow-y: auto; +} + +@media (max-width: 959.98px) { + .uauth-profile-card { + height: auto; + max-height: none; + overflow: visible; + } +} + +@media (max-width: 600px) { + .uauth-operations { + gap: 18px; + } + + .uauth-operation { + min-height: 62px; + padding: 10px; + } +} + +@media (max-width: 360px) { + .uauth-operation-grid { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .uauth-operation { + transition: none; + } +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/CreateUserDialogResult.cs b/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/CreateUserDialogResult.cs new file mode 100644 index 00000000..bd48b717 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/Contracts/CreateUserDialogResult.cs @@ -0,0 +1,8 @@ +namespace CodeBeam.UltimateAuth.Sample.Shared.Components.Dialogs; + +public sealed record CreateUserDialogResult(bool IsSuccess, string? ErrorMessage = null) +{ + public static CreateUserDialogResult Success() => new(true); + + public static CreateUserDialogResult Failure(string message) => new(false, message); +} diff --git a/samples/CodeBeam.UltimateAuth.Sample.Shared/wwwroot/uauth-dialogs.css b/samples/CodeBeam.UltimateAuth.Sample.Shared/wwwroot/uauth-dialogs.css new file mode 100644 index 00000000..9c1f11d3 --- /dev/null +++ b/samples/CodeBeam.UltimateAuth.Sample.Shared/wwwroot/uauth-dialogs.css @@ -0,0 +1,47 @@ +.uauth-shared-dialog { + border: 1px solid var(--mud-palette-lines-default); + border-radius: 18px !important; + background: var(--mud-palette-surface); + box-shadow: 0 24px 70px rgba(0, 0, 0, .16); + overflow: hidden; +} + + .uauth-shared-dialog .mud-dialog-title { + padding: 14px 20px; + background: var(--mud-palette-background); + border-bottom: 1px solid var(--mud-palette-lines-default); + } + + .uauth-shared-dialog .mud-dialog-content { + padding: 20px; + } + + .uauth-shared-dialog .mud-dialog-actions { + padding: 10px 20px; + border-top: 1px solid var(--mud-palette-lines-default); + } + +.uauth-dialog-backdrop { + backdrop-filter: blur(3px); +} + +@media (max-width: 600px) { + .mud-dialog.uauth-shared-dialog { + width: calc(100vw - 16px); + max-width: calc(100vw - 16px); + margin: 8px; + border-radius: 14px !important; + } + + .uauth-shared-dialog .mud-dialog-title { + padding: 12px 16px; + } + + .uauth-shared-dialog .mud-dialog-content { + padding: 16px; + } + + .uauth-shared-dialog .mud-dialog-actions { + padding: 10px 16px; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/App.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/App.razor index 8a0d2a78..da1985dd 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/App.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/App.razor @@ -15,6 +15,7 @@ + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor index 1dd1096b..9a0e6f30 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor @@ -7,10 +7,12 @@ + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html index 05649156..ed5354ac 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html @@ -9,9 +9,11 @@ + + diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/Components/App.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/Components/App.razor index 794bd19e..6eccdef2 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/Components/App.razor +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/Components/App.razor @@ -9,10 +9,12 @@ + +