enderecos cliente-1 cliente-2 e aviso se o cliente nao existe

This commit is contained in:
Armando Soares 2026-09-16 05:35:43 +00:00
parent ae39ca786b
commit 0fd708762c
12 changed files with 143 additions and 6 deletions

View File

@ -3,6 +3,7 @@ import {
HttpException,
HttpStatus,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@ -159,7 +160,7 @@ export class AuthService {
private async loginTenant(slug: string, email: string, password: string, ip?: string) {
const tenant = await this.platform.tenant.findUnique({ where: { slug } });
if (!tenant) {
throw new UnauthorizedException('Credenciais inválidas.');
throw new NotFoundException('Este cliente não existe.');
}
if (tenant.status !== 'ACTIVE') {
throw new ForbiddenException('Empresa suspensa.');
@ -205,8 +206,11 @@ export class AuthService {
private async loginTenantGoogle(slug: string, identity: GoogleIdentity, ip?: string) {
const tenant = await this.platform.tenant.findUnique({ where: { slug } });
if (!tenant || tenant.status !== 'ACTIVE') {
throw new UnauthorizedException('Credenciais inválidas.');
if (!tenant) {
throw new NotFoundException('Este cliente não existe.');
}
if (tenant.status !== 'ACTIVE') {
throw new ForbiddenException('Empresa suspensa.');
}
const db = this.tenants.getByDatabase(tenant.database);
let user =

View File

@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { PlatformController } from './platform.controller';
import { PlatformService } from './platform.service';
import { PublicTenantsController } from './public-tenants.controller';
@Module({
controllers: [PlatformController],
controllers: [PlatformController, PublicTenantsController],
providers: [PlatformService],
})
export class PlatformModule {}

View File

@ -3,6 +3,7 @@ import * as bcrypt from 'bcrypt';
import { randomBytes } from 'node:crypto';
import { PlatformPrismaService } from '../prisma/platform-prisma.service';
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database';
import { publicTenantView } from '../tenancy/public-tenant';
import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { normalizeSlug } from '../tenancy/tenant-url';
import type { CreateTenantDto } from './dto/create-tenant.dto';
@ -21,6 +22,20 @@ export class PlatformService {
});
}
async publicBySlug(raw: string) {
let slug: string;
try {
slug = normalizeSlug(raw);
} catch {
throw new NotFoundException('Este cliente não existe.');
}
const tenant = await this.platform.tenant.findUnique({
where: { slug },
select: { name: true, slug: true, status: true },
});
return publicTenantView(tenant);
}
async create(actorId: string, dto: CreateTenantDto, ip?: string) {
const slug = normalizeSlug(dto.slug);
const exists = await this.platform.tenant.findUnique({ where: { slug } });

View File

@ -0,0 +1,12 @@
import { Controller, Get, Param } from '@nestjs/common';
import { PlatformService } from './platform.service';
@Controller('tenants')
export class PublicTenantsController {
constructor(private readonly platform: PlatformService) {}
@Get(':slug')
bySlug(@Param('slug') slug: string) {
return this.platform.publicBySlug(slug);
}
}

View File

@ -0,0 +1,19 @@
import { NotFoundException } from '@nestjs/common';
import { publicTenantView } from './public-tenant';
describe('publicTenantView', () => {
it('hides internal fields and returns the public company', () => {
expect(
publicTenantView({
name: 'Cliente 1',
slug: 'cliente-1',
status: 'ACTIVE',
}),
).toEqual({ name: 'Cliente 1', slug: 'cliente-1', status: 'ACTIVE' });
});
it('tells the visitor when the company is missing', () => {
expect(() => publicTenantView(null)).toThrow(NotFoundException);
expect(() => publicTenantView(null)).toThrow('Este cliente não existe.');
});
});

View File

@ -0,0 +1,16 @@
import { NotFoundException } from '@nestjs/common';
export type PublicTenant = {
name: string;
slug: string;
status: string;
};
export function publicTenantView(
tenant: { name: string; slug: string; status: string } | null,
): PublicTenant {
if (!tenant) {
throw new NotFoundException('Este cliente não existe.');
}
return { name: tenant.name, slug: tenant.slug, status: tenant.status };
}

View File

@ -87,6 +87,7 @@ services:
environment:
PORT: 3000
HOSTNAME: 0.0.0.0
API_INTERNAL_URL: http://api:3001/api
ports:
- "${SINKA_WEB_PORT:-3000}:3000"
healthcheck:

View File

@ -21,6 +21,8 @@ ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV NEXT_TELEMETRY_DISABLED=1
ARG API_INTERNAL_URL=http://api:3001/api
ENV API_INTERNAL_URL=$API_INTERNAL_URL
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./

View File

@ -1,8 +1,11 @@
import { notFound } from "next/navigation";
import { RestrictedShell } from "@/components/restricted-shell";
import { isTenantSlug } from "@/lib/tenants";
import { TenantMissing } from "@/components/tenant-missing";
import { fetchPublicTenant, isTenantSlug } from "@/lib/tenants";
import type { ReactNode } from "react";
export const dynamic = "force-dynamic";
export default async function TenantPanelLayout({
children,
params,
@ -15,6 +18,11 @@ export default async function TenantPanelLayout({
notFound();
}
const company = await fetchPublicTenant(tenant);
if (!company) {
return <TenantMissing slug={tenant} />;
}
return (
<RestrictedShell kind="tenant" basePath={`/${tenant}`}>
{children}

View File

@ -1,7 +1,10 @@
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { LoginForm } from "@/components/login-form";
import { isTenantSlug } from "@/lib/tenants";
import { TenantMissing } from "@/components/tenant-missing";
import { fetchPublicTenant, isTenantSlug } from "@/lib/tenants";
export const dynamic = "force-dynamic";
export default async function TenantLoginPage({ params }: { params: Promise<{ tenant: string }> }) {
const { tenant } = await params;
@ -9,6 +12,11 @@ export default async function TenantLoginPage({ params }: { params: Promise<{ te
notFound();
}
const company = await fetchPublicTenant(tenant);
if (!company) {
return <TenantMissing slug={tenant} />;
}
return (
<Suspense fallback={<main className="flex min-h-dvh items-center justify-center bg-black text-[#E8F4E5]/60">Carregando</main>}>
<LoginForm variant="tenant" tenantSlug={tenant} />

View File

@ -0,0 +1,29 @@
import Image from "next/image";
export function TenantMissing({ slug }: { slug: string }) {
return (
<main className="flex min-h-dvh flex-col bg-black text-[#E8F4E5]">
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-6 py-16">
<a href="/" className="mb-10 block w-fit">
<Image
src="/brand/sinka-logo.webp"
alt="Sinka"
width={180}
height={58}
className="h-8 w-auto"
priority
/>
</a>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Cliente</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase leading-none">Este cliente não existe</h1>
<p className="mt-4 text-[#E8F4E5]/60">
Não empresa cadastrada em <span className="text-[#E8F4E5]">/{slug}</span>. Confira o endereço com quem
contratou a Sinka.
</p>
<a href="/" className="btn-sinka mt-8 w-fit">
Voltar
</a>
</div>
</main>
);
}

View File

@ -20,3 +20,25 @@ export function isReservedSlug(slug: string): boolean {
export function isTenantSlug(slug: string): boolean {
return /^[a-z][a-z0-9-]{1,30}$/.test(slug) && !isReservedSlug(slug);
}
export type PublicTenant = {
name: string;
slug: string;
status: string;
};
export async function fetchPublicTenant(slug: string): Promise<PublicTenant | null> {
const origin = (
process.env.API_INTERNAL_URL ??
process.env.NEXT_PUBLIC_API_URL ??
"http://localhost:3001/api"
).replace(/\/$/, "");
const response = await fetch(`${origin}/tenants/${encodeURIComponent(slug)}`, { cache: "no-store" });
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error("Não foi possível verificar o cliente.");
}
return response.json() as Promise<PublicTenant>;
}