changes: - migration to new DB
- Update onboarding process
This commit is contained in:
148
changelog.MD
148
changelog.MD
@@ -5,6 +5,154 @@ Consulta este archivo al inicio de cada sesión para entender el contexto y prog
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-29 - Mandatory Onboarding Guard System ✅
|
||||
|
||||
### 🔒 Sistema de Guard Obligatorio para Onboarding
|
||||
|
||||
Implementado un sistema completo que verifica si el API key de Meraki está configurado y bloquea el acceso al dashboard hasta completar el onboarding.
|
||||
|
||||
**Cambios realizados**:
|
||||
|
||||
#### Backend (.NET)
|
||||
1. **OnboardingService.cs** - Nuevo método `IsOnboardingComplete()`
|
||||
- Verifica si existe `APIKey` en `SplashTenantDetails` para el tenant actual
|
||||
- Retorna `true` si el API key está configurado, `false` en caso contrario
|
||||
- Ubicación: `src/SplashPage.Application/Onboarding/OnboardingService.cs:237-251`
|
||||
|
||||
2. **ApplicationInfoDto.cs** - Agregado campo `IsOnboardingComplete`
|
||||
- Propiedad booleana que indica el estado del onboarding
|
||||
- Se incluye en la respuesta de `GetCurrentLoginInformations`
|
||||
- Ubicación: `src/SplashPage.Application/Sessions/Dto/ApplicationInfoDto.cs:13`
|
||||
|
||||
3. **SessionService** - Integración con onboarding
|
||||
- El método `GetCurrentLoginInformations()` ahora llama a `IsOnboardingComplete()`
|
||||
- El estado se incluye en `ApplicationInfoDto` de la respuesta de sesión
|
||||
|
||||
#### Frontend (Next.js)
|
||||
1. **Type Definitions**
|
||||
- **ApplicationInfoDto.ts** - Agregado campo `isOnboardingComplete?: boolean`
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/api/types/ApplicationInfoDto.ts:25`
|
||||
- **next-auth.d.ts** - Extendidos tipos de NextAuth
|
||||
- Campo `isOnboardingComplete` en `Session.user`
|
||||
- Campo `isOnboardingComplete` en `JWT`
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/types/next-auth.d.ts:36,53`
|
||||
|
||||
2. **Authentication Flow**
|
||||
- **auth.ts** - Actualizado callback JWT
|
||||
- Captura `isOnboardingComplete` de la respuesta del backend
|
||||
- Se ejecuta en login inicial Y cuando se llama `update()` (trigger === 'update')
|
||||
- Almacena el estado en el token JWT
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/auth.ts:73-117`
|
||||
- **auth.ts** - Actualizado callback session
|
||||
- Pasa `isOnboardingComplete` del token a la sesión del cliente
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/auth.ts:125`
|
||||
|
||||
3. **OnboardingGuard Component** (NUEVO)
|
||||
- Componente cliente que envuelve el dashboard layout
|
||||
- Verifica `session.user.isOnboardingComplete`
|
||||
- Redirige a `/dashboard/onboarding` si es `false`
|
||||
- Permite acceso si es `true` o si ya está en la página de onboarding
|
||||
- Muestra loading state durante verificación
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/components/onboarding/OnboardingGuard.tsx`
|
||||
|
||||
4. **Dashboard Layout** - Integrado OnboardingGuard
|
||||
- Envuelve todo el contenido del dashboard con `<OnboardingGuard>`
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/app/dashboard/layout.tsx:23-35`
|
||||
|
||||
5. **SuccessStep** - Actualización de sesión post-onboarding
|
||||
- Importa `useSession` para obtener función `update()`
|
||||
- Llama a `update()` antes de redirigir al dashboard
|
||||
- Fuerza refresh del servidor con `router.refresh()`
|
||||
- Esto actualiza el flag `isOnboardingComplete` en la sesión
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/components/onboarding/steps/SuccessStep.tsx:16,51-59`
|
||||
|
||||
**Flujo de funcionamiento**:
|
||||
|
||||
1. **Login**:
|
||||
- Usuario inicia sesión
|
||||
- NextAuth llama al backend: `GetCurrentLoginInformations()`
|
||||
- Backend verifica si existe API key via `IsOnboardingComplete()`
|
||||
- Estado se almacena en sesión como `user.isOnboardingComplete`
|
||||
|
||||
2. **Acceso al Dashboard**:
|
||||
- OnboardingGuard verifica `session.user.isOnboardingComplete`
|
||||
- Si es `false`: Redirige a `/dashboard/onboarding`
|
||||
- Si es `true`: Permite acceso normal al dashboard
|
||||
- Si está en `/dashboard/onboarding`: Permite acceso (para completar el wizard)
|
||||
|
||||
3. **Completar Onboarding**:
|
||||
- Usuario completa el wizard de 7 pasos
|
||||
- Backend guarda API key en `SplashTenantDetails`
|
||||
- SuccessStep llama a `update()` para refrescar la sesión
|
||||
- NextAuth vuelve a llamar al backend con `trigger: 'update'`
|
||||
- Backend retorna `IsOnboardingComplete: true`
|
||||
- Usuario es redirigido al dashboard con acceso completo
|
||||
|
||||
**Beneficios**:
|
||||
- ✅ Onboarding obligatorio antes de usar el sistema
|
||||
- ✅ No se puede omitir o saltear
|
||||
- ✅ Verificación en cada carga de página (server-side)
|
||||
- ✅ Estado sincronizado con el backend
|
||||
- ✅ UX fluida con loading states apropiados
|
||||
- ✅ Actualización automática de sesión post-onboarding
|
||||
|
||||
**Archivos creados**:
|
||||
- `src/SplashPage.Web.Ui/src/components/onboarding/OnboardingGuard.tsx`
|
||||
- `src/SplashPage.Web.Ui/src/components/layout/DashboardLayoutClient.tsx`
|
||||
|
||||
**Archivos modificados**:
|
||||
- `src/SplashPage.Application/Onboarding/IOnboardingService.cs`
|
||||
- `src/SplashPage.Application/Onboarding/OnboardingService.cs`
|
||||
- `src/SplashPage.Application/Sessions/Dto/ApplicationInfoDto.cs` (ya tenía el campo)
|
||||
- `src/SplashPage.Web.Ui/src/api/types/ApplicationInfoDto.ts`
|
||||
- `src/SplashPage.Web.Ui/src/types/next-auth.d.ts`
|
||||
- `src/SplashPage.Web.Ui/src/auth.ts`
|
||||
- `src/SplashPage.Web.Ui/src/app/dashboard/layout.tsx`
|
||||
- `src/SplashPage.Web.Ui/src/components/onboarding/steps/SuccessStep.tsx`
|
||||
|
||||
### 🎨 UI Enhancement: Sidebar Hidden During Onboarding
|
||||
|
||||
**Mejora adicional**: La sidebar y el header ahora se ocultan automáticamente cuando el usuario está en la página de onboarding para proporcionar una experiencia más enfocada y sin distracciones.
|
||||
|
||||
**Implementación**:
|
||||
- Creado `DashboardLayoutClient.tsx` - Componente cliente que verifica la ruta actual
|
||||
- Usa `usePathname()` para detectar si está en `/dashboard/onboarding`
|
||||
- Si está en onboarding: Renderiza solo el contenido (sin sidebar/header)
|
||||
- Si está en cualquier otra ruta del dashboard: Renderiza el layout completo con sidebar y header
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/components/layout/DashboardLayoutClient.tsx`
|
||||
|
||||
**Ventajas**:
|
||||
- ✅ Experiencia de onboarding más inmersiva
|
||||
- ✅ No hay distracciones con navegación durante la configuración inicial
|
||||
- ✅ Transición automática al layout completo después de completar el onboarding
|
||||
- ✅ Código más limpio con separación de concerns (Server Component → Client Component)
|
||||
|
||||
### 🐛 Bug Fix: Eliminado scroll extra en página de onboarding
|
||||
|
||||
**Problema**: La página de onboarding mostraba un scroll vertical sin contenido adicional debajo, causado por múltiples `min-h-screen` anidados.
|
||||
|
||||
**Solución**:
|
||||
- **OnboardingWizard.tsx**:
|
||||
- Cambiado de `min-h-screen` a `h-screen` con `overflow-hidden` y `flex flex-col`
|
||||
- Contenedor interno cambió de `min-h-screen` a `flex-1 relative`
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/components/onboarding/OnboardingWizard.tsx:33,43`
|
||||
|
||||
- **WelcomeStep.tsx**:
|
||||
- Cambiado de `min-h-screen` a `absolute inset-0`
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/components/onboarding/steps/WelcomeStep.tsx:13`
|
||||
|
||||
- **SuccessStep.tsx**:
|
||||
- Cambiado de `min-h-screen` a `absolute inset-0`
|
||||
- Ubicación: `src/SplashPage.Web.Ui/src/components/onboarding/steps/SuccessStep.tsx:69`
|
||||
|
||||
**Resultado**:
|
||||
- ✅ Página ocupa exactamente la altura de la ventana (100vh)
|
||||
- ✅ No hay scroll innecesario
|
||||
- ✅ Mejor experiencia visual
|
||||
- ✅ Layout más predecible y controlado
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-29 - Onboarding Module: Full Implementation Complete ✅
|
||||
|
||||
### 🎉 Major Milestone: Next.js Onboarding Module Fully Implemented
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
using SplashPage.Onboarding.Dto;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using static SplashPage.Onboarding.OnboardingService;
|
||||
|
||||
|
||||
namespace SplashPage.Onboarding
|
||||
{
|
||||
public interface IOnboardingService : IApplicationService
|
||||
{
|
||||
Task<bool> ValidateApiKey(OnboardingApiKeyDto input);
|
||||
Task<ValidationApiKeyResult> ValidateApiKey(OnboardingApiKeyDto input);
|
||||
Task<List<OnboardingOrganizationOverviewDto>> GetOrganizations(OnboardingApiKeyDto input);
|
||||
Task<List<OnboardingOrganizationNetworksDto>> GetOrganizationsNetworks(OnboardingOrganizationDto input);
|
||||
Task FinishSetup(OnboardingFinishSetupDto input);
|
||||
Task<int> GetTenant();
|
||||
|
||||
Task<bool> IsOnboardingComplete();
|
||||
Task CreateDefaultCaptivePortal(string name, string description);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,18 +234,50 @@ namespace SplashPage.Onboarding
|
||||
return (await GetCurrentTenantAsync()).Id;
|
||||
}
|
||||
|
||||
public async Task<bool> ValidateApiKey(OnboardingApiKeyDto input)
|
||||
public async Task<bool> IsOnboardingComplete()
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = AbpSession.TenantId ?? 1;
|
||||
var tenantDetails = await _splashTenantDetailsRepository.FirstOrDefaultAsync(tenantId);
|
||||
|
||||
return tenantDetails != null && !string.IsNullOrWhiteSpace(tenantDetails.APIKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("Error checking onboarding status", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public class ValidationApiKeyResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
public async Task<ValidationApiKeyResult> ValidateApiKey(OnboardingApiKeyDto input)
|
||||
{
|
||||
try
|
||||
{
|
||||
var organizations = await _merakiService.GetOrganizations(input.ApiKey);
|
||||
|
||||
if (organizations.IsNullOrEmpty())
|
||||
{
|
||||
throw new UserFriendlyException("Api Key invalida");
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw new UserFriendlyException("Api Key invalida");
|
||||
return new ValidationApiKeyResult()
|
||||
{
|
||||
IsValid = false,
|
||||
Message = "Api Key invalida"
|
||||
};
|
||||
}
|
||||
return true;
|
||||
return new ValidationApiKeyResult()
|
||||
{
|
||||
IsValid = true,
|
||||
Message = "Api Key valida"
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,5 @@ public class ApplicationInfoDto
|
||||
public DateTime ReleaseDate { get; set; }
|
||||
|
||||
public Dictionary<string, bool> Features { get; set; }
|
||||
public bool IsOnboardingComplete { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Abp.Auditing;
|
||||
using SplashPage.Onboarding;
|
||||
using SplashPage.Sessions.Dto;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
@@ -7,16 +8,26 @@ namespace SplashPage.Sessions;
|
||||
|
||||
public class SessionAppService : SplashPageAppServiceBase, ISessionAppService
|
||||
{
|
||||
private readonly IOnboardingService _onboardingService;
|
||||
|
||||
public SessionAppService(IOnboardingService onboardingService)
|
||||
{
|
||||
_onboardingService = onboardingService;
|
||||
}
|
||||
|
||||
[DisableAuditing]
|
||||
public async Task<GetCurrentLoginInformationsOutput> GetCurrentLoginInformations()
|
||||
{
|
||||
var isOnboardingComplete = await _onboardingService.IsOnboardingComplete();
|
||||
|
||||
var output = new GetCurrentLoginInformationsOutput
|
||||
{
|
||||
Application = new ApplicationInfoDto
|
||||
{
|
||||
Version = AppVersionHelper.Version,
|
||||
ReleaseDate = AppVersionHelper.ReleaseDate,
|
||||
Features = new Dictionary<string, bool>()
|
||||
Features = new Dictionary<string, bool>(),
|
||||
IsOnboardingComplete = false//isOnboardingComplete
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
3733
src/SplashPage.EntityFrameworkCore/Migrations/20251029224938_Onboarding.Designer.cs
generated
Normal file
3733
src/SplashPage.EntityFrameworkCore/Migrations/20251029224938_Onboarding.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SplashPage.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Onboarding : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<List<int>>(
|
||||
name: "SelectedNetworkGroups",
|
||||
table: "SplashDashboards",
|
||||
type: "integer[]",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SplashNetworkGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
TenantId = table.Column<int>(type: "integer", nullable: false),
|
||||
CreationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatorUserId = table.Column<long>(type: "bigint", nullable: true),
|
||||
LastModificationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
LastModifierUserId = table.Column<long>(type: "bigint", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DeleterUserId = table.Column<long>(type: "bigint", nullable: true),
|
||||
DeletionTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SplashNetworkGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SplashNetworkGroupMembers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
NetworkGroupId = table.Column<int>(type: "integer", nullable: false),
|
||||
NetworkId = table.Column<int>(type: "integer", nullable: false),
|
||||
TenantId = table.Column<int>(type: "integer", nullable: false),
|
||||
CreationTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatorUserId = table.Column<long>(type: "bigint", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SplashNetworkGroupMembers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SplashNetworkGroupMembers_SplashMerakiNetworks_NetworkId",
|
||||
column: x => x.NetworkId,
|
||||
principalTable: "SplashMerakiNetworks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SplashNetworkGroupMembers_SplashNetworkGroups_NetworkGroupId",
|
||||
column: x => x.NetworkGroupId,
|
||||
principalTable: "SplashNetworkGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SplashNetworkGroupMembers_NetworkGroupId_NetworkId",
|
||||
table: "SplashNetworkGroupMembers",
|
||||
columns: new[] { "NetworkGroupId", "NetworkId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SplashNetworkGroupMembers_NetworkId",
|
||||
table: "SplashNetworkGroupMembers",
|
||||
column: "NetworkId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SplashNetworkGroups_Name_TenantId",
|
||||
table: "SplashNetworkGroups",
|
||||
columns: new[] { "Name", "TenantId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SplashNetworkGroupMembers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SplashNetworkGroups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SelectedNetworkGroups",
|
||||
table: "SplashDashboards");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1901,6 +1901,9 @@ namespace SplashPage.Migrations
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.PrimitiveCollection<List<int>>("SelectedNetworkGroups")
|
||||
.HasColumnType("integer[]");
|
||||
|
||||
b.PrimitiveCollection<List<int>>("SelectedNetworks")
|
||||
.HasColumnType("integer[]");
|
||||
|
||||
@@ -2239,6 +2242,91 @@ namespace SplashPage.Migrations
|
||||
b.ToTable("SplashMerakiOrganizations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashNetworkGroup", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("CreatorUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("DeleterUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTime?>("DeletionTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastModificationTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("LastModifierUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<int>("TenantId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name", "TenantId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SplashNetworkGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashNetworkGroupMember", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreationTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long?>("CreatorUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("NetworkGroupId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("NetworkId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TenantId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NetworkId");
|
||||
|
||||
b.HasIndex("NetworkGroupId", "NetworkId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SplashNetworkGroupMembers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashScheduledEmail", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -2708,7 +2796,8 @@ namespace SplashPage.Migrations
|
||||
.HasColumnName("ConnectionId");
|
||||
|
||||
b.Property<int>("ConnectionRank")
|
||||
.HasColumnType("integer");
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("ConnectionRank");
|
||||
|
||||
b.Property<string>("ConnectionStatus")
|
||||
.HasMaxLength(50)
|
||||
@@ -2718,6 +2807,10 @@ namespace SplashPage.Migrations
|
||||
b.Property<string>("ConnectionTime")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("DaysInactive")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("DaysInactive");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
@@ -2751,6 +2844,10 @@ namespace SplashPage.Migrations
|
||||
.HasColumnType("character varying(45)")
|
||||
.HasColumnName("IPAddress");
|
||||
|
||||
b.Property<bool>("IsRecoveredUser")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("IsRecoveredUser");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("LastSeen");
|
||||
@@ -2783,7 +2880,8 @@ namespace SplashPage.Migrations
|
||||
.HasColumnName("MonthNumber");
|
||||
|
||||
b.Property<int>("NetworkId")
|
||||
.HasColumnType("integer");
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("NetworkId");
|
||||
|
||||
b.Property<string>("NetworkName")
|
||||
.HasMaxLength(100)
|
||||
@@ -2804,6 +2902,23 @@ namespace SplashPage.Migrations
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("Platform");
|
||||
|
||||
b.Property<int>("PostRecoveryRank")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("PostRecoveryRank");
|
||||
|
||||
b.Property<DateTime?>("RecoveryConnectionDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("RecoveryConnectionDate");
|
||||
|
||||
b.Property<string>("SessionDurationCategory")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("SessionDurationCategory");
|
||||
|
||||
b.Property<decimal?>("UserAvgEstimatedMinutes")
|
||||
.HasColumnType("decimal(18,6)")
|
||||
.HasColumnName("UserAvgEstimatedMinutes");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("UserId");
|
||||
@@ -2831,6 +2946,252 @@ namespace SplashPage.Migrations
|
||||
b.ToView("splash_wifi_connection_report", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashWifiConnectionReportUnique", b =>
|
||||
{
|
||||
b.Property<string>("APMac")
|
||||
.HasMaxLength(17)
|
||||
.HasColumnType("character varying(17)")
|
||||
.HasColumnName("APMac");
|
||||
|
||||
b.Property<string>("APModel")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("APModel");
|
||||
|
||||
b.Property<string>("AccessPoint")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("AccessPoint");
|
||||
|
||||
b.Property<int?>("Age")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("Age");
|
||||
|
||||
b.Property<string>("AgeGroup")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("AgeGroup");
|
||||
|
||||
b.Property<string>("Browser")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("Browser");
|
||||
|
||||
b.Property<DateOnly>("ConnectionDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("ConnectionDate");
|
||||
|
||||
b.Property<DateTime>("ConnectionDateTime")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ConnectionDateTime");
|
||||
|
||||
b.Property<int>("ConnectionHour")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("ConnectionHour");
|
||||
|
||||
b.Property<int>("ConnectionId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("ConnectionId");
|
||||
|
||||
b.Property<int>("ConnectionRank")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("ConnectionRank");
|
||||
|
||||
b.Property<string>("ConnectionStatus")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("ConnectionStatus");
|
||||
|
||||
b.Property<string>("ConnectionTime")
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("ConnectionTime");
|
||||
|
||||
b.Property<int>("DaysInactive")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("DaysInactive");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("Description");
|
||||
|
||||
b.Property<string>("DeviceIdentifier")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("DeviceIdentifier");
|
||||
|
||||
b.Property<string>("DeviceMac")
|
||||
.HasMaxLength(17)
|
||||
.HasColumnType("character varying(17)")
|
||||
.HasColumnName("DeviceMac");
|
||||
|
||||
b.Property<decimal>("DurationMinutes")
|
||||
.HasColumnType("decimal(18,2)")
|
||||
.HasColumnName("DurationMinutes");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("Email");
|
||||
|
||||
b.Property<DateTime>("FirstSeen")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("FirstSeen");
|
||||
|
||||
b.Property<string>("IPAddress")
|
||||
.HasMaxLength(45)
|
||||
.HasColumnType("character varying(45)")
|
||||
.HasColumnName("IPAddress");
|
||||
|
||||
b.Property<bool>("IsRecoveredUser")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("IsRecoveredUser");
|
||||
|
||||
b.Property<DateTime>("LastSeen")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("LastSeen");
|
||||
|
||||
b.Property<string>("Latitude")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("Latitude");
|
||||
|
||||
b.Property<string>("Longitude")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("Longitude");
|
||||
|
||||
b.Property<string>("LoyaltyStatus")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("LoyaltyStatus");
|
||||
|
||||
b.Property<string>("LoyaltyType")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("LoyaltyType");
|
||||
|
||||
b.Property<string>("MerakiNetworkId")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("MerakiNetworkId");
|
||||
|
||||
b.Property<string>("MonthName")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("MonthName");
|
||||
|
||||
b.Property<int>("MonthNumber")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("MonthNumber");
|
||||
|
||||
b.Property<int>("NetworkId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("NetworkId");
|
||||
|
||||
b.Property<string>("NetworkName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("NetworkName");
|
||||
|
||||
b.Property<string>("NetworkUsage")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("NetworkUsage");
|
||||
|
||||
b.Property<string>("Organization")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("Organization");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("Platform");
|
||||
|
||||
b.Property<int>("PostRecoveryRank")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("PostRecoveryRank");
|
||||
|
||||
b.Property<DateTime?>("RecoveryConnectionDate")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("RecoveryConnectionDate");
|
||||
|
||||
b.Property<string>("SessionDurationCategory")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("SessionDurationCategory");
|
||||
|
||||
b.Property<int>("TotalDevicesUsed")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("TotalDevicesUsed");
|
||||
|
||||
b.Property<decimal?>("UserAvgEstimatedMinutes")
|
||||
.HasColumnType("decimal(18,6)")
|
||||
.HasColumnName("UserAvgEstimatedMinutes");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("UserId");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("UserName");
|
||||
|
||||
b.Property<string>("WeekDayName")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("WeekDayName");
|
||||
|
||||
b.Property<int>("WeekDayNumber")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("WeekDayNumber");
|
||||
|
||||
b.Property<int>("Year")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("Year");
|
||||
|
||||
b.ToTable((string)null);
|
||||
|
||||
b.ToView("splash_wifi_connection_report_unique", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashWifiConnectionReportUniqueByBranch", b =>
|
||||
{
|
||||
b.Property<DateTime>("ConnectionDate")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("NetworkGroup")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("NetworkId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("NetworkName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Organization")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("TotalConnections")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalLoyal")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalNew")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalRecovered")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalRecurrent")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.ToTable((string)null);
|
||||
|
||||
b.ToView("splash_wifi_connection_report_unique_by_branch", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashWifiScanningReport", b =>
|
||||
{
|
||||
b.Property<string>("AccessPointModel")
|
||||
@@ -3203,6 +3564,25 @@ namespace SplashPage.Migrations
|
||||
b.Navigation("Organization");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashNetworkGroupMember", b =>
|
||||
{
|
||||
b.HasOne("SplashPage.Splash.SplashNetworkGroup", "NetworkGroup")
|
||||
.WithMany("Networks")
|
||||
.HasForeignKey("NetworkGroupId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("SplashPage.Splash.SplashMerakiNetwork", "Network")
|
||||
.WithMany()
|
||||
.HasForeignKey("NetworkId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Network");
|
||||
|
||||
b.Navigation("NetworkGroup");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashScheduledEmail", b =>
|
||||
{
|
||||
b.HasOne("SplashPage.Splash.SplashEmailTemplate", "EmailTemplate")
|
||||
@@ -3335,6 +3715,11 @@ namespace SplashPage.Migrations
|
||||
b.Navigation("Widgets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashNetworkGroup", b =>
|
||||
{
|
||||
b.Navigation("Networks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SplashPage.Splash.SplashUser", b =>
|
||||
{
|
||||
b.Navigation("Connections");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"ConnectionStrings": {
|
||||
//"Default": "Server=172.21.4.113;Database=SplashPageBase;User=Jordi;Password=SqlS3rv3r;Encrypt=True;TrustServerCertificate=True;",
|
||||
//"Default": "Server=45.168.234.22;Port=5000;Database=SULTANES_db_Splash;Password=Bpusr001;User=root",
|
||||
"Default": "User ID=mysql;Password=Bpusr001;Host=45.168.234.22;Port=5001;Database=lc_db_splash;Pooling=true;Minimum Pool Size=20;Maximum Pool Size=150;Connection Idle Lifetime=300;Connection Pruning Interval=5;Timeout=30;Command Timeout=360;"
|
||||
"Default": "User ID=mysql;Password=Bpusr001;Host=45.168.234.22;Port=5001;Database=onboarding;Pooling=true;Minimum Pool Size=20;Maximum Pool Size=150;Connection Idle Lifetime=300;Connection Pruning Interval=5;Timeout=30;Command Timeout=360;"
|
||||
//"Default": "User ID=mysql;Password=Bpusr001;Host=2.tcp.ngrok.io;Port=11925;Database=nazan_db_splash;Pooling=true;Command Timeout=360;"
|
||||
},
|
||||
"App": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"ConnectionStrings": {
|
||||
//"Default": "Server=172.21.4.113;Database=SplashPageBase;User=Jordi;Password=SqlS3rv3r;Encrypt=True;TrustServerCertificate=True;",
|
||||
//"Default": "Server=45.168.234.22;Port=5000;Database=SULTANES_db_Splash;Password=Bpusr001;User=root",
|
||||
"Default": "User ID=mysql;Password=Bpusr001;Host=45.168.234.22;Port=5001;Database=nazan_db_splash;Pooling=true;Minimum Pool Size=20;Maximum Pool Size=150;Connection Idle Lifetime=300;Connection Pruning Interval=5;Timeout=30;Command Timeout=360;"
|
||||
"Default": "User ID=mysql;Password=Bpusr001;Host=45.168.234.22;Port=5001;Database=onboarding ;Pooling=true;Minimum Pool Size=20;Maximum Pool Size=150;Connection Idle Lifetime=300;Connection Pruning Interval=5;Timeout=30;Command Timeout=360;"
|
||||
//"Default": "User ID=mysql;Password=Bpusr001;Host=2.tcp.ngrok.io;Port=11925;Database=nazan_db_splash;Pooling=true;Command Timeout=360;"
|
||||
},
|
||||
"Authentication": {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import KBar from '@/components/kbar';
|
||||
import AppSidebar from '@/components/layout/app-sidebar';
|
||||
import Header from '@/components/layout/header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { OnboardingGuard } from '@/components/onboarding/OnboardingGuard';
|
||||
import { DashboardLayoutClient } from '@/components/layout/DashboardLayoutClient';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
@@ -18,17 +16,12 @@ export default async function DashboardLayout({
|
||||
// Persisting the sidebar state in the cookie.
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true"
|
||||
|
||||
return (
|
||||
<KBar>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
{/* page main content */}
|
||||
{children}
|
||||
{/* page main content ends */}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</KBar>
|
||||
<OnboardingGuard>
|
||||
<DashboardLayoutClient defaultOpen={defaultOpen}>
|
||||
{children}
|
||||
</DashboardLayoutClient>
|
||||
</OnboardingGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,19 +70,23 @@ export const authConfig: NextAuthConfig = {
|
||||
signIn: '/auth/sign-in',
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user, account }) {
|
||||
async jwt({ token, user, account, trigger }) {
|
||||
// Initial sign in - store backend access token and fetch user info
|
||||
if (user) {
|
||||
token.accessToken = user.accessToken;
|
||||
token.userId = user.id;
|
||||
token.expireInSeconds = user.expireInSeconds;
|
||||
token.encryptedAccessToken = user.encryptedAccessToken;
|
||||
}
|
||||
|
||||
// Fetch user info on initial sign in OR when session is updated (e.g., after onboarding)
|
||||
if (user || trigger === 'update') {
|
||||
const accessToken = token.accessToken as string;
|
||||
|
||||
// Fetch complete user information from backend
|
||||
try {
|
||||
const userInfo = await getApiServicesAppSessionGetcurrentlogininformations({
|
||||
headers: {
|
||||
Authorization: `Bearer ${user.accessToken}`
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
@@ -99,10 +103,16 @@ export const authConfig: NextAuthConfig = {
|
||||
} else {
|
||||
token.tenantId = null; // HOST user (no tenant)
|
||||
}
|
||||
|
||||
// Capture onboarding status from application info
|
||||
if (userInfo?.application) {
|
||||
token.isOnboardingComplete = userInfo.application.isOnboardingComplete || false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user info:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
@@ -117,6 +127,7 @@ export const authConfig: NextAuthConfig = {
|
||||
userName: token.userName as string,
|
||||
surname: token.surname as string,
|
||||
tenantId: token.tenantId as number | null,
|
||||
isOnboardingComplete: token.isOnboardingComplete as boolean,
|
||||
};
|
||||
}
|
||||
return session;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import KBar from '@/components/kbar';
|
||||
import AppSidebar from '@/components/layout/app-sidebar';
|
||||
import Header from '@/components/layout/header';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
|
||||
interface DashboardLayoutClientProps {
|
||||
children: React.ReactNode;
|
||||
defaultOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side layout wrapper that conditionally renders sidebar/header
|
||||
* based on the current route. During onboarding, only content is shown.
|
||||
*/
|
||||
export function DashboardLayoutClient({
|
||||
children,
|
||||
defaultOpen
|
||||
}: DashboardLayoutClientProps) {
|
||||
const pathname = usePathname();
|
||||
const isOnboardingPage = pathname === '/dashboard/onboarding';
|
||||
|
||||
// If on onboarding page, render only children without sidebar/header
|
||||
if (isOnboardingPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Normal dashboard layout with sidebar and header
|
||||
return (
|
||||
<KBar>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<Header />
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</KBar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* OnboardingGuard Component
|
||||
*
|
||||
* Verifies if the API key is configured (onboarding complete).
|
||||
* If not complete, redirects to the onboarding page.
|
||||
* This guard wraps the dashboard layout to ensure onboarding
|
||||
* is completed before accessing any dashboard functionality.
|
||||
*/
|
||||
export function OnboardingGuard({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isOnboardingPage = pathname === '/dashboard/onboarding';
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for session to load
|
||||
if (status === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're already on the onboarding page, don't redirect
|
||||
if (isOnboardingPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if onboarding is complete
|
||||
const isOnboardingComplete = session?.user?.isOnboardingComplete;
|
||||
|
||||
// If onboarding is not complete, redirect to onboarding
|
||||
if (isOnboardingComplete === false) {
|
||||
router.push('/dashboard/onboarding');
|
||||
}
|
||||
}, [session, status, isOnboardingPage, router]);
|
||||
|
||||
// Show loading state while checking session
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If onboarding is not complete and we're not on the onboarding page, show loading
|
||||
// (while the redirect is happening)
|
||||
if (!isOnboardingPage && session?.user?.isOnboardingComplete === false) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">Redirigiendo a configuración inicial...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Allow access to content
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ProgressBar } from './ProgressBar';
|
||||
import { WelcomeStep } from './steps/WelcomeStep';
|
||||
import { SelectTechStep } from './steps/SelectTechStep';
|
||||
@@ -22,15 +22,26 @@ export const OnboardingWizard = () => {
|
||||
|
||||
const totalSteps = 6; // Not counting welcome (0) and success (7)
|
||||
|
||||
const nextStep = () => setCurrentStep((prev) => prev + 1);
|
||||
const nextStep = () => {
|
||||
console.log('📍 nextStep() called. Current step:', currentStep);
|
||||
setCurrentStep((prev) => {
|
||||
console.log('📍 Setting step from', prev, 'to', prev + 1);
|
||||
return prev + 1;
|
||||
});
|
||||
};
|
||||
const goBack = () => setCurrentStep((prev) => Math.max(0, prev - 1));
|
||||
|
||||
const updateState = (updates: Partial<OnboardingState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
// Debug: log step changes
|
||||
useEffect(() => {
|
||||
console.log('🔄 Current step changed to:', currentStep);
|
||||
}, [currentStep]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative">
|
||||
<div className="h-screen bg-background relative overflow-hidden flex flex-col">
|
||||
{/* Progress bar - shown for steps 1-6 */}
|
||||
{currentStep > 0 && currentStep < 7 && (
|
||||
<ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
|
||||
@@ -39,8 +50,8 @@ export const OnboardingWizard = () => {
|
||||
{/* Step 0: Welcome Screen */}
|
||||
{currentStep === 0 && <WelcomeStep onNext={nextStep} />}
|
||||
|
||||
{/* Main step container - absolute positioning for smooth transitions */}
|
||||
<div className="relative min-h-screen">
|
||||
{/* Main step container */}
|
||||
<div className="flex-1 relative">
|
||||
{/* Step 1: Technology Selection */}
|
||||
{currentStep === 1 && (
|
||||
<SelectTechStep isActive={true} onNext={nextStep} />
|
||||
@@ -52,8 +63,8 @@ export const OnboardingWizard = () => {
|
||||
isActive={true}
|
||||
onNext={nextStep}
|
||||
onApiKeyValidated={(apiKey) => {
|
||||
console.log('💾 OnboardingWizard: Saving API key to state');
|
||||
updateState({ apiKey });
|
||||
nextStep();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { StepCard } from '../StepCard';
|
||||
import { Eye, EyeOff, Check, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useValidateApiKey } from '@/hooks/api/useOnboardingApi';
|
||||
import { usePostApiServicesAppOnboardingserviceValidateapikey } from '@/api/hooks/usePostApiServicesAppOnboardingserviceValidateapikey';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ApiKeyStepProps {
|
||||
@@ -24,34 +24,58 @@ export const ApiKeyStep = ({
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [shake, setShake] = useState(false);
|
||||
const [validationStatus, setValidationStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
const minLength = 16; // Legacy uses 16, but Meraki standard is 40
|
||||
const isLongEnough = apiKey.length >= minLength;
|
||||
|
||||
const {
|
||||
mutate: validateApiKey,
|
||||
isPending: isLoading,
|
||||
isSuccess,
|
||||
isError
|
||||
} = useValidateApiKey();
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 300);
|
||||
toast.error('API Key inválida. Por favor verifica e intenta nuevamente.');
|
||||
}
|
||||
}, [isError]);
|
||||
isPending: isLoading
|
||||
} = usePostApiServicesAppOnboardingserviceValidateapikey();
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!isLongEnough) return;
|
||||
|
||||
setValidationStatus('idle');
|
||||
console.log('🔑 Validating API key...');
|
||||
|
||||
validateApiKey(
|
||||
{ apiKey },
|
||||
{ data: { apiKey } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onApiKeyValidated(apiKey);
|
||||
setTimeout(onNext, 600);
|
||||
onSuccess: (result) => {
|
||||
console.log('✅ API Key validation response:', result);
|
||||
console.log('✅ Type of result:', typeof result);
|
||||
console.log('✅ result.isValid:', result?.isValid);
|
||||
console.log('✅ Full result structure:', JSON.stringify(result, null, 2));
|
||||
|
||||
// Check if the API key is valid from the DTO
|
||||
if (result?.isValid) {
|
||||
console.log('✅ API Key is valid!');
|
||||
setValidationStatus('success');
|
||||
// Save the API key first
|
||||
onApiKeyValidated(apiKey);
|
||||
console.log('💾 API Key saved to state');
|
||||
// Wait a bit to show the success state before transitioning
|
||||
setTimeout(() => {
|
||||
console.log('➡️ Calling onNext() to advance step');
|
||||
onNext();
|
||||
}, 600);
|
||||
} else {
|
||||
// API key is invalid according to backend
|
||||
console.error('❌ API Key is invalid:', result?.message);
|
||||
setValidationStatus('error');
|
||||
toast.error(result?.message || 'API Key inválida. Por favor verifica e intenta nuevamente.');
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 300);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ API Key validation request failed:', error);
|
||||
setValidationStatus('error');
|
||||
toast.error('Error al validar la API Key. Por favor intenta nuevamente.');
|
||||
setShake(true);
|
||||
setTimeout(() => setShake(false), 300);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -61,14 +85,14 @@ export const ApiKeyStep = ({
|
||||
if (isLoading) {
|
||||
return <Loader2 className="h-5 w-5 animate-spin text-warning" />;
|
||||
}
|
||||
if (isSuccess) {
|
||||
if (validationStatus === 'success') {
|
||||
return (
|
||||
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>
|
||||
<Check className="h-5 w-5 text-success" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
if (validationStatus === 'error') {
|
||||
return <AlertCircle className="h-5 w-5 text-destructive" />;
|
||||
}
|
||||
return null;
|
||||
@@ -104,8 +128,8 @@ export const ApiKeyStep = ({
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleValidate()}
|
||||
className={`
|
||||
pr-20 text-base h-14 transition-all
|
||||
${isSuccess ? 'border-success focus-visible:ring-success' : ''}
|
||||
${isError ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
${validationStatus === 'success' ? 'border-success focus-visible:ring-success' : ''}
|
||||
${validationStatus === 'error' ? 'border-destructive focus-visible:ring-destructive' : ''}
|
||||
`}
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
@@ -137,7 +161,7 @@ export const ApiKeyStep = ({
|
||||
<div className="flex items-center justify-center pt-4">
|
||||
<RippleButton
|
||||
variant={
|
||||
isLoading ? 'default' : isSuccess ? 'success' : 'hero'
|
||||
isLoading ? 'default' : validationStatus === 'success' ? 'success' : 'hero'
|
||||
}
|
||||
size="lg"
|
||||
onClick={handleValidate}
|
||||
@@ -149,7 +173,7 @@ export const ApiKeyStep = ({
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Validando...
|
||||
</>
|
||||
) : isSuccess ? (
|
||||
) : validationStatus === 'success' ? (
|
||||
<>
|
||||
<Check className="mr-2 h-5 w-5" />
|
||||
Validado
|
||||
|
||||
@@ -7,7 +7,7 @@ import { StepCard } from '../StepCard';
|
||||
import { Wifi, Check, Loader2 } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useGetOrganizationNetworks } from '@/hooks/api/useOnboardingApi';
|
||||
import { useGetApiServicesAppOnboardingserviceGetorganizationsnetworks } from '@/api/hooks/useGetApiServicesAppOnboardingserviceGetorganizationsnetworks';
|
||||
|
||||
interface PickNetworksStepProps {
|
||||
isActive: boolean;
|
||||
@@ -25,10 +25,10 @@ export const PickNetworksStep = ({
|
||||
const [selectedNetworks, setSelectedNetworks] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
const { data: networks, isLoading } = useGetOrganizationNetworks(
|
||||
apiKey,
|
||||
organizationId
|
||||
);
|
||||
const { data: networks, isLoading } = useGetApiServicesAppOnboardingserviceGetorganizationsnetworks({
|
||||
ApiKey: apiKey,
|
||||
OrganizationId: organizationId
|
||||
});
|
||||
|
||||
const toggleNetwork = (id: string) => {
|
||||
setSelectedNetworks((prev) =>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Camera,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { useGetOrganizations } from '@/hooks/api/useOnboardingApi';
|
||||
import { useGetApiServicesAppOnboardingserviceGetorganizations } from '@/api/hooks/useGetApiServicesAppOnboardingserviceGetorganizations';
|
||||
import { parseOrganizationDevices } from '@/lib/onboarding-utils';
|
||||
import type { Organization } from '@/types/onboarding';
|
||||
|
||||
@@ -25,7 +25,7 @@ export const PickOrgStep = ({
|
||||
apiKey,
|
||||
onOrgSelected
|
||||
}: PickOrgStepProps) => {
|
||||
const { data: organizations, isLoading, isError } = useGetOrganizations(apiKey);
|
||||
const { data: organizations, isLoading, isError } = useGetApiServicesAppOnboardingserviceGetorganizations({ ApiKey: apiKey });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { CheckCircle2, Sparkles } from 'lucide-react';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
@@ -12,6 +13,7 @@ interface SuccessStepProps {
|
||||
|
||||
export const SuccessStep = ({ isActive }: SuccessStepProps) => {
|
||||
const router = useRouter();
|
||||
const { update } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
@@ -46,8 +48,14 @@ export const SuccessStep = ({ isActive }: SuccessStepProps) => {
|
||||
}, 250);
|
||||
|
||||
// Auto-redirect after 3 seconds
|
||||
const redirectTimeout = setTimeout(() => {
|
||||
router.push('/dashboard?id=1');
|
||||
const redirectTimeout = setTimeout(async () => {
|
||||
// Force session refresh to update isOnboardingComplete flag
|
||||
await update();
|
||||
// Small delay to ensure session is updated
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard?id=1');
|
||||
router.refresh(); // Force a server-side refresh
|
||||
}, 100);
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
@@ -58,7 +66,7 @@ export const SuccessStep = ({ isActive }: SuccessStepProps) => {
|
||||
}, [isActive, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-8 relative overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center p-8 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-meraki-green/10 via-background to-success/10" />
|
||||
|
||||
<motion.div
|
||||
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
Loader2,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useGetOrganizationNetworks,
|
||||
useFinishSetup
|
||||
} from '@/hooks/api/useOnboardingApi';
|
||||
import { useGetApiServicesAppOnboardingserviceGetorganizationsnetworks } from '@/api/hooks/useGetApiServicesAppOnboardingserviceGetorganizationsnetworks';
|
||||
import { usePostApiServicesAppOnboardingserviceFinishsetup } from '@/api/hooks/usePostApiServicesAppOnboardingserviceFinishsetup';
|
||||
import { getLicenseBracket } from '@/lib/onboarding-utils';
|
||||
import { toast } from 'sonner';
|
||||
import type { OnboardingState } from '@/types/onboarding';
|
||||
@@ -33,12 +31,12 @@ export const SummaryStep = ({
|
||||
onFinish,
|
||||
onBack
|
||||
}: SummaryStepProps) => {
|
||||
const { data: allNetworks } = useGetOrganizationNetworks(
|
||||
state.apiKey,
|
||||
state.selectedOrg?.organizationId || ''
|
||||
);
|
||||
const { data: allNetworks } = useGetApiServicesAppOnboardingserviceGetorganizationsnetworks({
|
||||
ApiKey: state.apiKey,
|
||||
OrganizationId: state.selectedOrg?.organizationId || ''
|
||||
});
|
||||
|
||||
const { mutate: finishSetup, isPending: isLoading } = useFinishSetup();
|
||||
const { mutate: finishSetup, isPending: isLoading } = usePostApiServicesAppOnboardingserviceFinishsetup();
|
||||
|
||||
const selectedNetworkObjects = useMemo(() => {
|
||||
return (
|
||||
@@ -64,19 +62,23 @@ export const SummaryStep = ({
|
||||
|
||||
finishSetup(
|
||||
{
|
||||
organizationId: state.selectedOrg.organizationId,
|
||||
networks: state.selectedNetworks,
|
||||
apiKey: state.apiKey
|
||||
data: {
|
||||
organizationId: state.selectedOrg.organizationId,
|
||||
networks: state.selectedNetworks,
|
||||
apiKey: state.apiKey
|
||||
}
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('¡Configuración completada exitosamente!');
|
||||
setTimeout(onFinish, 600);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
'Error al finalizar la configuración. Por favor intenta nuevamente.'
|
||||
);
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('¡Configuración completada exitosamente!');
|
||||
setTimeout(onFinish, 600);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
'Error al finalizar la configuración. Por favor intenta nuevamente.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface WelcomeStepProps {
|
||||
|
||||
export const WelcomeStep = ({ onNext }: WelcomeStepProps) => {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-8 relative overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center p-8 overflow-hidden">
|
||||
{/* Animated gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-meraki-green/20 via-background to-meraki-green-light/20 animate-pulse" />
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const API_BASE = '/api/services/app/OnboardingService';
|
||||
*/
|
||||
export const useValidateApiKey = () => {
|
||||
return useMutation<ValidateApiKeyResponse, Error, ValidateApiKeyRequest>({
|
||||
mutationKey: ['validateApiKey'],
|
||||
mutationFn: async ({ apiKey }) => {
|
||||
const response = await abpAxiosClient.post<ValidateApiKeyResponse>(
|
||||
`${API_BASE}/ValidateApiKey`,
|
||||
|
||||
@@ -132,6 +132,12 @@ abpAxiosClient.interceptors.request.use(
|
||||
}
|
||||
}
|
||||
|
||||
// Fix Content-Type header - Kubb generates 'application/*+json' from ABP Swagger
|
||||
// which can cause double JSON serialization. Force it to 'application/json'
|
||||
if (config.headers['Content-Type'] === 'application/*+json') {
|
||||
config.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -33,6 +33,7 @@ declare module 'next-auth' {
|
||||
userName?: string;
|
||||
surname?: string;
|
||||
tenantId?: number | null;
|
||||
isOnboardingComplete?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -49,5 +50,6 @@ declare module 'next-auth/jwt' {
|
||||
userName?: string;
|
||||
surname?: string;
|
||||
tenantId?: number | null;
|
||||
isOnboardingComplete?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user