create-dialog.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. /**
  2. * Create-telegram-bot dialog. Renders as a Radix Dialog triggered
  3. * by a Button. The form collects the bot fields, including the
  4. * one-time bot_token paste.
  5. *
  6. * The bot_token is write-only: the UI shows it as a password
  7. * field and never reads it back from the server. The operator
  8. * pastes a token they got from @BotFather; the server stores
  9. * the plaintext (so telegramd can use it) and bcrypt-hashes it
  10. * for the `bot_token_hash` column. After a successful create
  11. * the form clears the token field.
  12. *
  13. * Renders nothing if the user lacks create-bot permission
  14. * (super_admin only — see canManageTelegram in
  15. * web/src/lib/scope.ts).
  16. */
  17. import { useEffect, useState } from 'react';
  18. import { useForm } from 'react-hook-form';
  19. import { zodResolver } from '@hookform/resolvers/zod';
  20. import { z } from 'zod';
  21. import { Eye, EyeOff, Plus, Send } from 'lucide-react';
  22. import { toast } from 'sonner';
  23. import { Button } from '@/components/ui/button';
  24. import { Input } from '@/components/ui/input';
  25. import { Label } from '@/components/ui/label';
  26. import { Textarea } from '@/components/ui/textarea';
  27. import {
  28. Dialog,
  29. DialogContent,
  30. DialogDescription,
  31. DialogFooter,
  32. DialogHeader,
  33. DialogTitle,
  34. DialogTrigger,
  35. } from '@/components/ui/dialog';
  36. import { getErrorMessage, useCreateTelegramBot } from './api';
  37. const ID_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
  38. // Telegram bot tokens look like `<bot_id>:<secret>` where
  39. // bot_id is decimal digits and secret is 35+ [A-Za-z0-9_-]
  40. // chars. We accept the same shape documented in
  41. // M13b_PLAN §2.3.
  42. const TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/;
  43. const formSchema = z.object({
  44. id: z
  45. .string()
  46. .min(2, 'Bot id must be 2–64 characters')
  47. .max(64, 'Bot id must be 2–64 characters')
  48. .regex(ID_RE, 'Lowercase letters, digits, and dashes only'),
  49. name: z.string().min(1, 'Name is required').max(200, 'Name must be \u2264 200 characters'),
  50. bot_token: z
  51. .string()
  52. .min(1, 'Bot token is required')
  53. .regex(TOKEN_RE, 'Token must look like 12345678:AbCdEfGh... (35+ chars after the colon)'),
  54. welcome_message: z
  55. .string()
  56. .max(4096, 'Welcome message must be \u2264 4096 characters')
  57. .optional()
  58. .or(z.literal('')),
  59. default_source_id: z
  60. .string()
  61. .regex(ID_RE, 'Lowercase letters, digits, and dashes only')
  62. .optional()
  63. .or(z.literal('')),
  64. description: z
  65. .string()
  66. .max(500, 'Description must be \u2264 500 characters')
  67. .optional()
  68. .or(z.literal('')),
  69. });
  70. type FormValues = z.infer<typeof formSchema>;
  71. const EMPTY_DEFAULTS: FormValues = {
  72. id: '',
  73. name: '',
  74. bot_token: '',
  75. welcome_message: '',
  76. default_source_id: '',
  77. description: '',
  78. };
  79. export function CreateTelegramBotDialog({ tenantId }: { tenantId: string }) {
  80. const [open, setOpen] = useState(false);
  81. const [showToken, setShowToken] = useState(false);
  82. const create = useCreateTelegramBot(tenantId);
  83. const form = useForm<FormValues>({
  84. resolver: zodResolver(formSchema),
  85. defaultValues: EMPTY_DEFAULTS,
  86. });
  87. // Reset form when the dialog opens.
  88. useEffect(() => {
  89. if (open) form.reset(EMPTY_DEFAULTS);
  90. }, [open, form]);
  91. const onSubmit = form.handleSubmit(async (values) => {
  92. try {
  93. const created = await create.mutateAsync({
  94. id: values.id.trim(),
  95. name: values.name.trim(),
  96. bot_token: values.bot_token.trim(),
  97. welcome_message: values.welcome_message || undefined,
  98. default_source_id: values.default_source_id || undefined,
  99. description: values.description || undefined,
  100. });
  101. toast.success(`Bot "${created.name}" created.`);
  102. // Clear the token field on success; it has been stored
  103. // server-side and will never be re-shown.
  104. form.setValue('bot_token', '');
  105. setShowToken(false);
  106. setOpen(false);
  107. } catch (err) {
  108. toast.error(getErrorMessage(err));
  109. }
  110. });
  111. return (
  112. <Dialog open={open} onOpenChange={setOpen}>
  113. <DialogTrigger asChild>
  114. <Button>
  115. <Plus className="mr-2 h-4 w-4" />
  116. New bot
  117. </Button>
  118. </DialogTrigger>
  119. <DialogContent className="sm:max-w-lg">
  120. <DialogHeader>
  121. <DialogTitle className="flex items-center gap-2">
  122. <Send className="h-4 w-4" />
  123. New Telegram bot
  124. </DialogTitle>
  125. <DialogDescription>
  126. Paste a bot token from <strong>@BotFather</strong>. The token is
  127. stored encrypted server-side and never shown again after this
  128. dialog closes. telegramd will pick it up on its next reload.
  129. </DialogDescription>
  130. </DialogHeader>
  131. <form onSubmit={onSubmit} className="flex flex-col gap-4">
  132. <div className="grid grid-cols-2 gap-3">
  133. <div className="col-span-1 flex flex-col gap-1">
  134. <Label htmlFor="bot-id">ID</Label>
  135. <Input id="bot-id" placeholder="primary" {...form.register('id')} />
  136. {form.formState.errors.id ? (
  137. <p className="text-xs text-destructive">{form.formState.errors.id.message}</p>
  138. ) : null}
  139. </div>
  140. <div className="col-span-1 flex flex-col gap-1">
  141. <Label htmlFor="bot-name">Name</Label>
  142. <Input id="bot-name" placeholder="Acme Ops" {...form.register('name')} />
  143. {form.formState.errors.name ? (
  144. <p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
  145. ) : null}
  146. </div>
  147. </div>
  148. <div className="flex flex-col gap-1">
  149. <Label htmlFor="bot-token">Bot token</Label>
  150. <div className="flex items-center gap-2">
  151. <Input
  152. id="bot-token"
  153. type={showToken ? 'text' : 'password'}
  154. placeholder="123456789:AbCdEfGhIjKlMnOpQrStUvWxYz-12345"
  155. autoComplete="off"
  156. spellCheck={false}
  157. {...form.register('bot_token')}
  158. />
  159. <Button
  160. type="button"
  161. variant="ghost"
  162. size="sm"
  163. onClick={() => setShowToken((v) => !v)}
  164. aria-label={showToken ? 'Hide token' : 'Show token'}
  165. >
  166. {showToken ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
  167. </Button>
  168. </div>
  169. {form.formState.errors.bot_token ? (
  170. <p className="text-xs text-destructive">
  171. {form.formState.errors.bot_token.message}
  172. </p>
  173. ) : (
  174. <p className="text-xs text-muted-foreground">
  175. Get this from @BotFather in Telegram. The shape is
  176. <code className="ml-1 rounded bg-muted px-1 py-0.5 text-[10px]">
  177. &lt;bot_id&gt;:&lt;secret&gt;
  178. </code>
  179. .
  180. </p>
  181. )}
  182. </div>
  183. <div className="flex flex-col gap-1">
  184. <Label htmlFor="bot-default-source">Default source ID (optional)</Label>
  185. <Input
  186. id="bot-default-source"
  187. placeholder="primary"
  188. {...form.register('default_source_id')}
  189. />
  190. <p className="text-xs text-muted-foreground">
  191. If set, the bot will be the default for alerts coming from this source.
  192. </p>
  193. {form.formState.errors.default_source_id ? (
  194. <p className="text-xs text-destructive">
  195. {form.formState.errors.default_source_id.message}
  196. </p>
  197. ) : null}
  198. </div>
  199. <div className="flex flex-col gap-1">
  200. <Label htmlFor="bot-welcome">Welcome message (optional)</Label>
  201. <Textarea
  202. id="bot-welcome"
  203. rows={2}
  204. placeholder="Welcome to Acme alerts! Reply /help to see available commands."
  205. {...form.register('welcome_message')}
  206. />
  207. <p className="text-xs text-muted-foreground">
  208. Sent in response to /start. M13c will wire this into telegramd.
  209. </p>
  210. {form.formState.errors.welcome_message ? (
  211. <p className="text-xs text-destructive">
  212. {form.formState.errors.welcome_message.message}
  213. </p>
  214. ) : null}
  215. </div>
  216. <div className="flex flex-col gap-1">
  217. <Label htmlFor="bot-desc">Description (optional)</Label>
  218. <Textarea
  219. id="bot-desc"
  220. rows={2}
  221. placeholder="What does this bot do?"
  222. {...form.register('description')}
  223. />
  224. {form.formState.errors.description ? (
  225. <p className="text-xs text-destructive">
  226. {form.formState.errors.description.message}
  227. </p>
  228. ) : null}
  229. </div>
  230. <DialogFooter>
  231. <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
  232. Cancel
  233. </Button>
  234. <Button type="submit" disabled={create.isPending}>
  235. {create.isPending ? 'Creating…' : 'Create bot'}
  236. </Button>
  237. </DialogFooter>
  238. </form>
  239. </DialogContent>
  240. </Dialog>
  241. );
  242. }