@@ -0,0 +1,86 @@
1+// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved.
2+// Use of this source code is governed by a GPL license that can be found in the LICENSE file.
3+module main
4+
5+import time
6+
7+struct Org {
8+ id int @[primary; sql: serial]
9+ name string @[unique]
10+ contact_email string
11+ kind string
12+ created_at time.Time
13+ created_by int
14+}
15+
16+struct OrgMember {
17+ id int @[primary; sql: serial]
18+ org_id int @[unique: 'org_member']
19+ user_id int @[unique: 'org_member']
20+ role string
21+}
22+
23+pub fn (mut app App) add_org(name string, contact_email string, kind string, created_by int) !int {
24+ new_org := Org{
25+ name: name
26+ contact_email: contact_email
27+ kind: kind
28+ created_at: time.now()
29+ created_by: created_by
30+ }
31+ sql app.db {
32+ insert new_org into Org
33+ }!
34+ row := app.get_org_by_name(name) or { return error('failed to load newly created org') }
35+ return row.id
36+}
37+
38+pub fn (app App) get_org_by_name(name string) ?Org {
39+ rows := sql app.db {
40+ select from Org where name == name limit 1
41+ } or { [] }
42+ if rows.len == 0 {
43+ return none
44+ }
45+ return rows.first()
46+}
47+
48+pub fn (app App) get_org_by_id(id int) ?Org {
49+ rows := sql app.db {
50+ select from Org where id == id limit 1
51+ } or { [] }
52+ if rows.len == 0 {
53+ return none
54+ }
55+ return rows.first()
56+}
57+
58+pub fn (mut app App) add_org_member(org_id int, user_id int, role string) ! {
59+ member := OrgMember{
60+ org_id: org_id
61+ user_id: user_id
62+ role: role
63+ }
64+ sql app.db {
65+ insert member into OrgMember
66+ }!
67+}
68+
69+pub fn (app App) find_orgs_for_user(user_id int) []Org {
70+ members := sql app.db {
71+ select from OrgMember where user_id == user_id
72+ } or { [] }
73+ mut orgs := []Org{cap: members.len}
74+ for m in members {
75+ org := app.get_org_by_id(m.org_id) or { continue }
76+ orgs << org
77+ }
78+ return orgs
79+}
80+
81+pub fn (app App) is_org_member(org_id int, user_id int) bool {
82+ count := sql app.db {
83+ select count from OrgMember where org_id == org_id && user_id == user_id
84+ } or { 0 }
85+ return count > 0
86+}
@@ -0,0 +1,68 @@
1+// Copyright (c) 2019-2026 Alexander Medvednikov. All rights reserved.
2+// Use of this source code is governed by a GPL license that can be found in the LICENSE file.
3+module main
4+
5+import veb
6+import validation
7+
8+@['/organizations/new']
9+pub fn (mut app App) new_org(mut ctx Context) veb.Result {
10+ if !ctx.logged_in {
11+ return ctx.redirect_to_login()
12+ }
13+ return $veb.html('templates/new/org.html')
14+}
15+
16+@['/organizations/new'; post]
17+pub fn (mut app App) handle_new_org(mut ctx Context) veb.Result {
18+ if !ctx.logged_in {
19+ return ctx.redirect_to_login()
20+ }
21+ org_name := ctx.form['org_name']
22+ contact_email := ctx.form['contact_email']
23+ org_kind := ctx.form['org_kind']
24+ accept_terms := ctx.form['accept_terms'] == '1'
25+
26+ if validation.is_string_empty(org_name) {
27+ ctx.error('Organization name is required')
28+ return app.new_org(mut ctx)
29+ }
30+ if org_name.len > max_username_len {
31+ ctx.error('The organization name is too long (should be fewer than ${max_username_len} characters)')
32+ return app.new_org(mut ctx)
33+ }
34+ if org_name.contains(' ') {
35+ ctx.error('Organization name cannot contain spaces')
36+ return app.new_org(mut ctx)
37+ }
38+ if validation.is_string_empty(contact_email) {
39+ ctx.error('Contact email is required')
40+ return app.new_org(mut ctx)
41+ }
42+ if org_kind != 'personal' && org_kind != 'business' {
43+ ctx.error('Please select who this organization belongs to')
44+ return app.new_org(mut ctx)
45+ }
46+ if !accept_terms {
47+ ctx.error('You must accept the Terms of Service')
48+ return app.new_org(mut ctx)
49+ }
50+ if _ := app.get_user_by_username(org_name) {
51+ ctx.error('The name "${org_name}" is already taken')
52+ return app.new_org(mut ctx)
53+ }
54+ if _ := app.get_org_by_name(org_name) {
55+ ctx.error('The name "${org_name}" is already taken')
56+ return app.new_org(mut ctx)
57+ }
58+
59+ org_id := app.add_org(org_name, contact_email, org_kind, ctx.user.id) or {
60+ ctx.error('Could not create organization: ${err}')
61+ return app.new_org(mut ctx)
62+ }
63+ app.add_org_member(org_id, ctx.user.id, 'admin') or {
64+ ctx.error('Could not add you as the organization owner: ${err}')
65+ return app.new_org(mut ctx)
66+ }
67+ return ctx.redirect('/new?owner=${org_name}')
68+}
@@ -0,0 +1,87 @@
1+<!DOCTYPE html>
2+<html>
3+ <head>
4+ @include '../layout/head.html'
5+ </head>
6+ <body>
7+ @include '../layout/header.html'
8+
9+ <div class="content">
10+ <div class="new-org">
11+ <div class="new-org__header">
12+ <p class="new-org__subtitle">%new_org_subtitle</p>
13+ <h1>%new_org_title</h1>
14+ </div>
15+
16+ @if ctx.form_error != ''
17+ <div class='form-error'>@ctx.form_error</div>
18+ @end
19+
20+ <form class="new-org__form" method='post' action='/organizations/new'>
21+ <div class="new-org__field">
22+ <label class="new-org__label" for="org_name">%new_org_name <span class="new-org__req">*</span></label>
23+ <input id="org_name" class="new-org__input" type='text' name='org_name' required autofocus maxlength=@max_username_len>
24+ <p class="new-org__hint">%new_org_name_hint</p>
25+ <p class="new-org__hint">%new_org_url_hint https://@app.config.hostname/<span class="new-org__url-slot" id="url_slot"></span></p>
26+ </div>
27+
28+ <div class="new-org__field">
29+ <label class="new-org__label" for="contact_email">%new_org_contact_email <span class="new-org__req">*</span></label>
30+ <input id="contact_email" class="new-org__input" type='email' name='contact_email' required>
31+ </div>
32+
33+ <div class="new-org__field">
34+ <p class="new-org__label">%new_org_belongs_to</p>
35+ <label class="new-org__option">
36+ <input type="radio" name="org_kind" value="personal" required>
37+ <span class="new-org__option-body">
38+ <span class="new-org__option-title">%new_org_personal</span>
39+ <span class="new-org__option-desc">
40+ %new_org_personal_example @ctx.user.username
41+ @if ctx.user.full_name != ''
42+ (@ctx.user.full_name)
43+ @end
44+ </span>
45+ </span>
46+ </label>
47+ <label class="new-org__option">
48+ <input type="radio" name="org_kind" value="business" required>
49+ <span class="new-org__option-body">
50+ <span class="new-org__option-title">%new_org_business</span>
51+ <span class="new-org__option-desc">%new_org_business_example</span>
52+ </span>
53+ </label>
54+ </div>
55+
56+ <div class="new-org__field new-org__field--inline">
57+ <label class="new-org__checkbox">
58+ <input type="checkbox" id="accept_terms" name="accept_terms" value="1" required>
59+ <span>%new_org_accept_terms</span>
60+ </label>
61+ </div>
62+
63+ <div class="new-org__actions">
64+ <input id="new_org_submit" type='submit' class='new-org__submit' value='%new_org_next' disabled>
65+ </div>
66+ </form>
67+ </div>
68+ </div>
69+
70+ <script>
71+ (function() {
72+ var nameInput = document.getElementById('org_name');
73+ var slot = document.getElementById('url_slot');
74+ var terms = document.getElementById('accept_terms');
75+ var submit = document.getElementById('new_org_submit');
76+ function syncUrl() { slot.textContent = nameInput.value; }
77+ function syncSubmit() { submit.disabled = !terms.checked; }
78+ nameInput.addEventListener('input', syncUrl);
79+ terms.addEventListener('change', syncSubmit);
80+ syncUrl();
81+ syncSubmit();
82+ })();
83+ </script>
84+
85+ @include '../layout/footer.html'
86+ </body>
87+</html>