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

View File

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

View File

@ -3,6 +3,7 @@ import * as bcrypt from 'bcrypt';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { PlatformPrismaService } from '../prisma/platform-prisma.service'; import { PlatformPrismaService } from '../prisma/platform-prisma.service';
import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database'; import { createMysqlDatabase, databaseForSlug, pushTenantSchema } from '../tenancy/provision-database';
import { publicTenantView } from '../tenancy/public-tenant';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { normalizeSlug } from '../tenancy/tenant-url'; import { normalizeSlug } from '../tenancy/tenant-url';
import type { CreateTenantDto } from './dto/create-tenant.dto'; 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) { async create(actorId: string, dto: CreateTenantDto, ip?: string) {
const slug = normalizeSlug(dto.slug); const slug = normalizeSlug(dto.slug);
const exists = await this.platform.tenant.findUnique({ where: { 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: environment:
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
API_INTERNAL_URL: http://api:3001/api
ports: ports:
- "${SINKA_WEB_PORT:-3000}:3000" - "${SINKA_WEB_PORT:-3000}:3000"
healthcheck: healthcheck:

View File

@ -21,6 +21,8 @@ ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
ENV NEXT_TELEMETRY_DISABLED=1 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/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./

View File

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

View File

@ -1,7 +1,10 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { LoginForm } from "@/components/login-form"; 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 }> }) { export default async function TenantLoginPage({ params }: { params: Promise<{ tenant: string }> }) {
const { tenant } = await params; const { tenant } = await params;
@ -9,6 +12,11 @@ export default async function TenantLoginPage({ params }: { params: Promise<{ te
notFound(); notFound();
} }
const company = await fetchPublicTenant(tenant);
if (!company) {
return <TenantMissing slug={tenant} />;
}
return ( return (
<Suspense fallback={<main className="flex min-h-dvh items-center justify-center bg-black text-[#E8F4E5]/60">Carregando</main>}> <Suspense fallback={<main className="flex min-h-dvh items-center justify-center bg-black text-[#E8F4E5]/60">Carregando</main>}>
<LoginForm variant="tenant" tenantSlug={tenant} /> <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 { export function isTenantSlug(slug: string): boolean {
return /^[a-z][a-z0-9-]{1,30}$/.test(slug) && !isReservedSlug(slug); 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>;
}