17 KiB
Implementation Plan: Bot Users
This plan implements bot users as first-class users with an is_bot flag, gated behind a service.enablebotusers config flag. It incorporates all gap resolutions from the design review.
Reference: Design Plan | Gap Analysis
Phase 1: Backend Foundation
Step 1.1: Config Key
File: pkg/config/config.go
- Add
ServiceEnableBotUsers Key = "service.enablebotusers"alongside existingServiceEnable*keys (around line 52-65). - In
InitDefaultConfig(), addServiceEnableBotUsers.setDefault(false).
File: config-raw.json
- Add entry in the
servicesection (after existingenableuserdeletionblock around line 107):
{
"key": "enablebotusers",
"default_value": "false",
"comment": "If enabled, users can create bot users that interact with Vikunja via the API only."
}
Step 1.2: User Struct Changes
File: pkg/user/user.go
- Add
IsBotfield to theUserstruct:
IsBot bool `xorm:"bool default false index" json:"is_bot"`
- Update
ShouldNotify()(line 146-160) to returnfalsefor bots:
if user.IsBot {
return false, nil
}
Step 1.3: Error Types
File: pkg/user/error.go
Add four new error types after ErrorCodeTokenUserMismatch = 1029 (line 707+):
| Error Type | Code | HTTP Status | When |
|---|---|---|---|
ErrAccountIsBot |
1030 | 412 | Bot attempts password login |
ErrBotUsersDisabled |
1031 | 403 | Bot endpoint called when feature is off |
ErrBotNotOwned |
1032 | 403 | User tries to manage a bot they don't own |
ErrCannotMakeBotOwner |
1033 | 400 | Trying to set a bot as owner of another bot |
ErrBotUsernameMustHavePrefix |
1034 | 400 | Bot username doesn't start with bot- |
Follow the existing error pattern: struct, IsErr*() check, Error() string, ErrCode* constant, HTTPError() method.
Step 1.4: Bot Owner Model & Database Migration
New file: pkg/models/bot_users.go
Define BotOwner struct:
type BotOwner struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
BotID int64 `xorm:"bigint not null unique index" json:"bot_id"`
OwnerID int64 `xorm:"bigint not null index" json:"owner_id"`
Created time.Time `xorm:"created not null" json:"created"`
}
func (*BotOwner) TableName() string {
return "bot_owners"
}
File: pkg/models/models.go
- Add
&BotOwner{}toGetTables()return slice (line 45-73).
New file: pkg/migration/<timestamp>.go
- Run
mage dev:make-migration BotOwnerto generate migration file. - Migration adds:
is_botcolumn touserstable (bool, default false, indexed)bot_ownerstable viatx.Sync2(&BotOwner{})
Step 1.5: CreateBotUser Function
File: pkg/user/user_create.go
Add CreateBotUser() function. This does NOT call checkIfUserIsValid() or checkIfUserExists() — it has its own validation:
- Validate username: not empty, no spaces, starts with
bot-, not a duplicate (viaGetUserByUsername) - Validate name (display name): optional, free-form
- Create user with:
IsBot = trueStatus = StatusActiveIssuer = IssuerLocalPassword = ""(no password)Email = ""(no email)EmailRemindersEnabled = falseOverdueTasksRemindersEnabled = false- All other defaults from config
- Insert user
- Dispatch
CreatedEvent - Return the new user
Also add to checkIfUserIsValid() (line 130-153): reject bot- prefix for non-bot users:
if strings.HasPrefix(user.Username, "bot-") {
return ErrUsernameReserved{Username: user.Username}
}
Step 1.6: Block Login for Bots
File: pkg/routes/api/v1/login.go
After the status check at line 74-77, add:
if user.IsBot {
_ = s.Rollback()
return &user2.ErrAccountIsBot{UserID: user.ID}
}
This blocks bots after both LDAP and local auth paths converge. Even if LDAP somehow resolves a bot user, it gets blocked here.
File: pkg/routes/caldav/auth.go
Before c.Set("userBasicAuth", u) at line 63-64:
if u != nil && u.IsBot {
log.Warningf("CalDAV basic auth rejected for bot user %d", u.ID)
return false, nil
}
Step 1.7: API Token Auth - User Status Check (Security Fix)
File: pkg/routes/api_tokens.go
In checkAPITokenAndPutItInContext(), after user.GetUserByID() at line 94-97, add:
if u.Status == user.StatusDisabled {
log.Debugf("[auth] Tried authenticating with token %d but user %d is disabled", token.ID, u.ID)
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
This ensures disabling a bot (or any user) immediately revokes API token access.
Step 1.8: APIToken.Create() - Minimal Branching
File: pkg/models/api_tokens.go
In Create() (line 86-110), change line 102 from:
t.OwnerID = a.GetID()
to:
if t.OwnerID == 0 {
t.OwnerID = a.GetID()
}
This allows bot token handlers to pre-set OwnerID to the bot's ID.
Phase 2: Bot API Endpoints
Step 2.1: Bot CRUD Model Methods
File: pkg/models/bot_users.go (extend from Step 1.4)
Add a BotUser struct for the API layer (wraps user + ownership info). This struct implements web.CRUDable and web.Permissions for use with generic WebHandlers where possible.
However, because bot CRUD involves creating users (not just inserting a model row), custom handlers are needed for Create and Delete. ReadAll and ReadOne can use generic patterns.
Define the following methods on BotUser:
Create(s, a): Calluser.CreateBotUser(), then insertBotOwner{BotID: newUser.ID, OwnerID: a.GetID()}ReadAll(s, a, search, page, perPage): Querybot_owners WHERE owner_id = a.GetID()joined withusersReadOne(s, a): Get bot by ID, verify ownershipUpdate(s, a): Update bot'snameorusername(verify ownership, validatebot-prefix preserved)Delete(s, a): CallDeleteUser()frompkg/models/user_delete.goto hard-delete the bot and all related data. Also delete theBotOwnerrow. SkipAccountDeletedNotificationsinceShouldNotify()returns false for bots.
Add a Disable/Enable method (or use Update with a status field):
- Set
user.Status = StatusDisabledorStatusActive
New file: pkg/models/bot_users_permissions.go
Permission methods:
CanCreate(s, a): Return true ifconfig.ServiceEnableBotUsers.GetBool()and user is not a bot.CanRead(s, a): Return true if user owns this bot (checkbot_owners).CanUpdate(s, a): Same as CanRead.CanDelete(s, a): Same as CanRead.
Step 2.2: Bot Token Endpoints
File: pkg/routes/api/v1/bots.go (new file)
Custom handlers for bot token management since we need to override the token owner:
PUT /user/bots/:bot/tokens - Create token for bot:
- Parse bot ID from
:botparam - Verify current user owns this bot (query
bot_owners) - Bind
APITokenfrom request body - Set
token.OwnerID = bot.ID(pre-set before calling Create) - Call
token.Create(s, a)— uses the branched logic from Step 1.8 - Return the token (visible only once)
GET /user/bots/:bot/tokens - List bot's tokens:
- Verify ownership
- Query
api_tokens WHERE owner_id = bot.ID
DELETE /user/bots/:bot/tokens/:token - Delete bot's token:
- Verify ownership
- Delete
api_tokens WHERE id = :token AND owner_id = bot.ID
Step 2.3: Route Registration
File: pkg/routes/routes.go
In the user routes group (after line 462), add:
if config.ServiceEnableBotUsers.GetBool() {
botHandler := &handler.WebHandler{
EmptyStruct: func() handler.CObject {
return &models.BotUser{}
},
}
u.PUT("/bots", botHandler.CreateWeb)
u.GET("/bots", botHandler.ReadAllWeb)
u.GET("/bots/:bot", botHandler.ReadOneWeb)
u.POST("/bots/:bot", botHandler.UpdateWeb)
u.DELETE("/bots/:bot", botHandler.DeleteWeb)
// Bot token management (custom handlers)
u.PUT("/bots/:bot/tokens", apiv1.CreateBotToken)
u.GET("/bots/:bot/tokens", apiv1.ListBotTokens)
u.DELETE("/bots/:bot/tokens/:token", apiv1.DeleteBotToken)
}
Step 2.4: Info Endpoint
File: pkg/routes/api/v1/info.go
- Add
BotUsersEnabled bool \json:"bot_users_enabled"`tovikunjaInfos` struct (line 35-55). - In the
Infohandler, set:info.BotUsersEnabled = config.ServiceEnableBotUsers.GetBool().
Phase 3: Frontend
Step 3.1: Config Store
File: frontend/src/stores/config.ts
- Add to
ConfigStateinterface (afterpublicTeamsEnabledat line 46):
botUsersEnabled: boolean,
- Add default in reactive state (after
publicTeamsEnabled: falseat line 85):
botUsersEnabled: false,
No other changes needed — objectToCamelCase(config) in update() automatically maps bot_users_enabled → botUsersEnabled.
Step 3.2: User Type & Model
File: frontend/src/modelTypes/IUser.ts
Add to interface:
isBot: boolean
File: frontend/src/models/user.ts
Add default in constructor:
isBot = false
Step 3.3: Bot Badge in User Component
File: frontend/src/components/misc/User.vue
After the username <span>, add:
<span v-if="user?.isBot" class="bot-badge">{{ $t('user.bot.badge') }}</span>
Style:
.bot-badge {
display: inline-flex;
align-items: center;
font-size: 0.65rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
background: var(--grey-200);
color: var(--grey-600);
margin-inline-start: 0.25rem;
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.02em;
}
Step 3.4: Bot Service & Model
New file: frontend/src/services/botUser.ts
export default class BotUserService extends AbstractService<IBotUser> {
constructor() {
super({
create: '/user/bots',
getAll: '/user/bots',
get: '/user/bots/{id}',
update: '/user/bots/{id}',
delete: '/user/bots/{id}',
})
}
modelFactory(data) { return new BotUserModel(data) }
}
New file: frontend/src/services/botToken.ts
export default class BotTokenService extends AbstractService<IApiToken> {
constructor(botId: number) {
super({
create: `/user/bots/${botId}/tokens`,
getAll: `/user/bots/${botId}/tokens`,
delete: `/user/bots/${botId}/tokens/{id}`,
})
}
modelFactory(data) { return new ApiTokenModel(data) }
}
New file: frontend/src/modelTypes/IBotUser.ts
export interface IBotUser extends IAbstract {
id: number
username: string
name: string
isBot: boolean
created: Date
updated: Date
}
New file: frontend/src/models/botUser.ts
Model class extending AbstractModel<IBotUser>.
Step 3.5: Bot Settings Page
New file: frontend/src/views/user/settings/Bots.vue
Settings page with:
- List of bots owned by current user (fetched from
GET /user/bots) - Create bot form: username (with
bot-prefix pre-filled), display name - Per-bot actions:
- Edit (update display name)
- Disable/Enable toggle
- Delete (with confirmation dialog — make it clear this is permanent and irreversible)
- Per-bot token management:
- Create token (show token value once, copy-to-clipboard)
- List existing tokens
- Delete token
Follow the pattern of frontend/src/views/user/settings/ApiTokens.vue for the token management UI.
Step 3.6: Route & Navigation
File: frontend/src/router/index.ts
Add to user.settings children (after the webhooks route, line 147-151):
{
path: '/user/settings/bots',
name: 'user.settings.bots',
component: () => import('@/views/user/settings/Bots.vue'),
},
File: frontend/src/views/user/Settings.vue
Add computed:
const botUsersEnabled = computed(() => configStore.botUsersEnabled)
Add to navigationItems array (before the deletion entry):
{
title: t('user.settings.bots.title'),
routeName: 'user.settings.bots',
condition: botUsersEnabled.value,
},
Step 3.7: Translation Strings
File: frontend/src/i18n/lang/en.json
Add under user.settings:
"bots": {
"title": "Bots",
"create": "Create Bot",
"createDescription": "Bot users can interact with Vikunja via the API only. Bot usernames must start with 'bot-'.",
"username": "Bot Username",
"usernamePlaceholder": "bot-my-assistant",
"displayName": "Display Name",
"displayNamePlaceholder": "My Assistant Bot",
"noBotsYet": "You haven't created any bots yet.",
"delete": "Delete Bot",
"deleteConfirmation": "This will permanently delete this bot and all its data including task assignments, project memberships, and API tokens. This action cannot be undone.",
"disable": "Disable Bot",
"enable": "Enable Bot",
"disabled": "Disabled",
"tokens": "API Tokens",
"createToken": "Create Token",
"tokenCreated": "Token created. Copy it now — you won't be able to see it again.",
"noTokens": "No API tokens yet."
}
Add under user:
"bot": {
"badge": "Bot"
}
Phase 4: Testing
Step 4.1: Backend Test Fixtures
File: pkg/db/fixtures/users.yml (or appropriate fixture file)
Add bot user fixture entries for testing.
Step 4.2: Backend Tests
New file: pkg/models/bot_users_test.go
Test cases:
- Create bot successfully
- Create bot when feature disabled → error
- Create bot with invalid username (no
bot-prefix) → error - Create bot as a bot → error
- List bots returns only owned bots
- Update bot name
- Delete bot (verify cascading: task assignees, tokens, project memberships removed)
- Disable bot → verify API token auth fails
- Enable bot → verify API token auth works again
- Create token for bot
- Create token for bot not owned → error
- Login as bot → error
- CalDAV auth as bot → rejected
Step 4.3: Frontend Tests
Unit tests for bot service and model. The settings page can be covered by E2E tests if needed.
Implementation Order
- Phase 1 (Steps 1.1–1.8): Backend foundation — can be tested independently
- Phase 2 (Steps 2.1–2.4): API endpoints — testable with curl/API tests
- Phase 3 (Steps 3.1–3.7): Frontend — requires backend running
- Phase 4 (Steps 4.1–4.3): Tests — run alongside implementation
Within Phase 1, the steps are sequential (each depends on prior). Phase 2 depends on Phase 1. Phase 3 depends on Phase 2. Phase 4 can be written alongside Phases 1-3.
Files Summary
New Files (10)
| File | Purpose |
|---|---|
pkg/models/bot_users.go |
BotOwner + BotUser model, CRUD logic |
pkg/models/bot_users_permissions.go |
Permission checks |
pkg/models/bot_users_test.go |
Backend tests |
pkg/routes/api/v1/bots.go |
Bot token endpoint handlers |
pkg/migration/<timestamp>.go |
Database migration |
frontend/src/views/user/settings/Bots.vue |
Bot management UI |
frontend/src/services/botUser.ts |
Bot CRUD service |
frontend/src/services/botToken.ts |
Bot token service |
frontend/src/modelTypes/IBotUser.ts |
Bot TypeScript interface |
frontend/src/models/botUser.ts |
Bot model class |
Modified Files (15)
| File | Change |
|---|---|
pkg/config/config.go |
Add ServiceEnableBotUsers key + default |
config-raw.json |
Add enablebotusers schema entry |
pkg/user/user.go |
Add IsBot field; update ShouldNotify() |
pkg/user/user_create.go |
Add CreateBotUser(); reserve bot- prefix in checkIfUserIsValid() |
pkg/user/error.go |
Add 5 new error types (codes 1030-1034) |
pkg/models/models.go |
Add &BotOwner{} to GetTables() |
pkg/models/api_tokens.go |
Branch OwnerID assignment in Create() |
pkg/routes/routes.go |
Register bot API routes |
pkg/routes/api/v1/info.go |
Expose BotUsersEnabled in /info |
pkg/routes/api/v1/login.go |
Block bot login |
pkg/routes/api_tokens.go |
Add user status check (security fix) |
pkg/routes/caldav/auth.go |
Block bot CalDAV auth |
frontend/src/stores/config.ts |
Add botUsersEnabled to state |
frontend/src/modelTypes/IUser.ts |
Add isBot field |
frontend/src/models/user.ts |
Add isBot default |
frontend/src/components/misc/User.vue |
Add bot badge |
frontend/src/views/user/Settings.vue |
Add bots nav item |
frontend/src/router/index.ts |
Add bots route |
frontend/src/i18n/lang/en.json |
Add translation strings |