| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- /**
- * Create-telegram-bot dialog. Renders as a Radix Dialog triggered
- * by a Button. The form collects the bot fields, including the
- * one-time bot_token paste.
- *
- * The bot_token is write-only: the UI shows it as a password
- * field and never reads it back from the server. The operator
- * pastes a token they got from @BotFather; the server stores
- * the plaintext (so telegramd can use it) and bcrypt-hashes it
- * for the `bot_token_hash` column. After a successful create
- * the form clears the token field.
- *
- * Renders nothing if the user lacks create-bot permission
- * (super_admin only — see canManageTelegram in
- * web/src/lib/scope.ts).
- */
- import { useEffect, useState } from 'react';
- import { useForm } from 'react-hook-form';
- import { zodResolver } from '@hookform/resolvers/zod';
- import { z } from 'zod';
- import { Eye, EyeOff, Plus, Send } from 'lucide-react';
- import { toast } from 'sonner';
- import { Button } from '@/components/ui/button';
- import { Input } from '@/components/ui/input';
- import { Label } from '@/components/ui/label';
- import { Textarea } from '@/components/ui/textarea';
- import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
- } from '@/components/ui/dialog';
- import { getErrorMessage, useCreateTelegramBot } from './api';
- const ID_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
- // Telegram bot tokens look like `<bot_id>:<secret>` where
- // bot_id is decimal digits and secret is 35+ [A-Za-z0-9_-]
- // chars. We accept the same shape documented in
- // M13b_PLAN §2.3.
- const TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/;
- const formSchema = z.object({
- id: z
- .string()
- .min(2, 'Bot id must be 2–64 characters')
- .max(64, 'Bot id must be 2–64 characters')
- .regex(ID_RE, 'Lowercase letters, digits, and dashes only'),
- name: z.string().min(1, 'Name is required').max(200, 'Name must be \u2264 200 characters'),
- bot_token: z
- .string()
- .min(1, 'Bot token is required')
- .regex(TOKEN_RE, 'Token must look like 12345678:AbCdEfGh... (35+ chars after the colon)'),
- welcome_message: z
- .string()
- .max(4096, 'Welcome message must be \u2264 4096 characters')
- .optional()
- .or(z.literal('')),
- default_source_id: z
- .string()
- .regex(ID_RE, 'Lowercase letters, digits, and dashes only')
- .optional()
- .or(z.literal('')),
- description: z
- .string()
- .max(500, 'Description must be \u2264 500 characters')
- .optional()
- .or(z.literal('')),
- });
- type FormValues = z.infer<typeof formSchema>;
- const EMPTY_DEFAULTS: FormValues = {
- id: '',
- name: '',
- bot_token: '',
- welcome_message: '',
- default_source_id: '',
- description: '',
- };
- export function CreateTelegramBotDialog({ tenantId }: { tenantId: string }) {
- const [open, setOpen] = useState(false);
- const [showToken, setShowToken] = useState(false);
- const create = useCreateTelegramBot(tenantId);
- const form = useForm<FormValues>({
- resolver: zodResolver(formSchema),
- defaultValues: EMPTY_DEFAULTS,
- });
- // Reset form when the dialog opens.
- useEffect(() => {
- if (open) form.reset(EMPTY_DEFAULTS);
- }, [open, form]);
- const onSubmit = form.handleSubmit(async (values) => {
- try {
- const created = await create.mutateAsync({
- id: values.id.trim(),
- name: values.name.trim(),
- bot_token: values.bot_token.trim(),
- welcome_message: values.welcome_message || undefined,
- default_source_id: values.default_source_id || undefined,
- description: values.description || undefined,
- });
- toast.success(`Bot "${created.name}" created.`);
- // Clear the token field on success; it has been stored
- // server-side and will never be re-shown.
- form.setValue('bot_token', '');
- setShowToken(false);
- setOpen(false);
- } catch (err) {
- toast.error(getErrorMessage(err));
- }
- });
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- <Button>
- <Plus className="mr-2 h-4 w-4" />
- New bot
- </Button>
- </DialogTrigger>
- <DialogContent className="sm:max-w-lg">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Send className="h-4 w-4" />
- New Telegram bot
- </DialogTitle>
- <DialogDescription>
- Paste a bot token from <strong>@BotFather</strong>. The token is
- stored encrypted server-side and never shown again after this
- dialog closes. telegramd will pick it up on its next reload.
- </DialogDescription>
- </DialogHeader>
- <form onSubmit={onSubmit} className="flex flex-col gap-4">
- <div className="grid grid-cols-2 gap-3">
- <div className="col-span-1 flex flex-col gap-1">
- <Label htmlFor="bot-id">ID</Label>
- <Input id="bot-id" placeholder="primary" {...form.register('id')} />
- {form.formState.errors.id ? (
- <p className="text-xs text-destructive">{form.formState.errors.id.message}</p>
- ) : null}
- </div>
- <div className="col-span-1 flex flex-col gap-1">
- <Label htmlFor="bot-name">Name</Label>
- <Input id="bot-name" placeholder="Acme Ops" {...form.register('name')} />
- {form.formState.errors.name ? (
- <p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
- ) : null}
- </div>
- </div>
- <div className="flex flex-col gap-1">
- <Label htmlFor="bot-token">Bot token</Label>
- <div className="flex items-center gap-2">
- <Input
- id="bot-token"
- type={showToken ? 'text' : 'password'}
- placeholder="123456789:AbCdEfGhIjKlMnOpQrStUvWxYz-12345"
- autoComplete="off"
- spellCheck={false}
- {...form.register('bot_token')}
- />
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => setShowToken((v) => !v)}
- aria-label={showToken ? 'Hide token' : 'Show token'}
- >
- {showToken ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
- </Button>
- </div>
- {form.formState.errors.bot_token ? (
- <p className="text-xs text-destructive">
- {form.formState.errors.bot_token.message}
- </p>
- ) : (
- <p className="text-xs text-muted-foreground">
- Get this from @BotFather in Telegram. The shape is
- <code className="ml-1 rounded bg-muted px-1 py-0.5 text-[10px]">
- <bot_id>:<secret>
- </code>
- .
- </p>
- )}
- </div>
- <div className="flex flex-col gap-1">
- <Label htmlFor="bot-default-source">Default source ID (optional)</Label>
- <Input
- id="bot-default-source"
- placeholder="primary"
- {...form.register('default_source_id')}
- />
- <p className="text-xs text-muted-foreground">
- If set, the bot will be the default for alerts coming from this source.
- </p>
- {form.formState.errors.default_source_id ? (
- <p className="text-xs text-destructive">
- {form.formState.errors.default_source_id.message}
- </p>
- ) : null}
- </div>
- <div className="flex flex-col gap-1">
- <Label htmlFor="bot-welcome">Welcome message (optional)</Label>
- <Textarea
- id="bot-welcome"
- rows={2}
- placeholder="Welcome to Acme alerts! Reply /help to see available commands."
- {...form.register('welcome_message')}
- />
- <p className="text-xs text-muted-foreground">
- Sent in response to /start. M13c will wire this into telegramd.
- </p>
- {form.formState.errors.welcome_message ? (
- <p className="text-xs text-destructive">
- {form.formState.errors.welcome_message.message}
- </p>
- ) : null}
- </div>
- <div className="flex flex-col gap-1">
- <Label htmlFor="bot-desc">Description (optional)</Label>
- <Textarea
- id="bot-desc"
- rows={2}
- placeholder="What does this bot do?"
- {...form.register('description')}
- />
- {form.formState.errors.description ? (
- <p className="text-xs text-destructive">
- {form.formState.errors.description.message}
- </p>
- ) : null}
- </div>
- <DialogFooter>
- <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
- Cancel
- </Button>
- <Button type="submit" disabled={create.isPending}>
- {create.isPending ? 'Creating…' : 'Create bot'}
- </Button>
- </DialogFooter>
- </form>
- </DialogContent>
- </Dialog>
- );
- }
|