diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index b1b178f..af4332d 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -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 = diff --git a/api/src/platform/platform.module.ts b/api/src/platform/platform.module.ts index f58134a..6299210 100644 --- a/api/src/platform/platform.module.ts +++ b/api/src/platform/platform.module.ts @@ -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 {} diff --git a/api/src/platform/platform.service.ts b/api/src/platform/platform.service.ts index 67e50aa..64a9eb9 100644 --- a/api/src/platform/platform.service.ts +++ b/api/src/platform/platform.service.ts @@ -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 } }); diff --git a/api/src/platform/public-tenants.controller.ts b/api/src/platform/public-tenants.controller.ts new file mode 100644 index 0000000..fe5a974 --- /dev/null +++ b/api/src/platform/public-tenants.controller.ts @@ -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); + } +} diff --git a/api/src/tenancy/public-tenant.spec.ts b/api/src/tenancy/public-tenant.spec.ts new file mode 100644 index 0000000..7342603 --- /dev/null +++ b/api/src/tenancy/public-tenant.spec.ts @@ -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.'); + }); +}); diff --git a/api/src/tenancy/public-tenant.ts b/api/src/tenancy/public-tenant.ts new file mode 100644 index 0000000..27eaf19 --- /dev/null +++ b/api/src/tenancy/public-tenant.ts @@ -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 }; +} diff --git a/docker-compose.yml b/docker-compose.yml index 54a67ce..b060e62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/web/Dockerfile b/web/Dockerfile index 94bccf2..d9942d6 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -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 ./ diff --git a/web/src/app/[tenant]/(panel)/layout.tsx b/web/src/app/[tenant]/(panel)/layout.tsx index c96c8cc..3593ecd 100644 --- a/web/src/app/[tenant]/(panel)/layout.tsx +++ b/web/src/app/[tenant]/(panel)/layout.tsx @@ -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 ; + } + return ( {children} diff --git a/web/src/app/[tenant]/login/page.tsx b/web/src/app/[tenant]/login/page.tsx index c0ca48b..844956f 100644 --- a/web/src/app/[tenant]/login/page.tsx +++ b/web/src/app/[tenant]/login/page.tsx @@ -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 ; + } + return ( Carregando…}> diff --git a/web/src/components/tenant-missing.tsx b/web/src/components/tenant-missing.tsx new file mode 100644 index 0000000..d6f3002 --- /dev/null +++ b/web/src/components/tenant-missing.tsx @@ -0,0 +1,29 @@ +import Image from "next/image"; + +export function TenantMissing({ slug }: { slug: string }) { + return ( +
+
+ + Sinka + +

Cliente

+

Este cliente não existe

+

+ Não há empresa cadastrada em /{slug}. Confira o endereço com quem + contratou a Sinka. +

+ + Voltar + +
+
+ ); +} diff --git a/web/src/lib/tenants.ts b/web/src/lib/tenants.ts index fd6ad04..1c167d1 100644 --- a/web/src/lib/tenants.ts +++ b/web/src/lib/tenants.ts @@ -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 { + 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; +}