1
0
Fork 0

Compare commits

...

9 Commits

Author SHA1 Message Date
Daniel Herrmann 93672cae09 docs: regenerate swagger docs 2024-03-09 20:31:32 +00:00
Daniel Herrmann 74e6a6eb9b tests: add basic tests for public teams feature 2024-03-09 20:31:32 +00:00
Daniel Herrmann 11572332bc feat: add query param to team ReadAll function to include public teams when sharing 2024-03-09 20:31:32 +00:00
Daniel Herrmann 5035dd0e94 doc: add more details to openid spec 2024-03-09 20:31:32 +00:00
Daniel Herrmann 2f1b87536b feat: read isPublic flag from OIDC claims 2024-03-09 20:31:32 +00:00
Daniel Herrmann f67746d858 feat: add frontend support for public team setting 2024-03-09 20:31:32 +00:00
Daniel Herrmann 9bc3e7cdb7 feat: add IsPublic flag to team model 2024-03-09 20:31:32 +00:00
Daniel Herrmann 8ea302899d feat: add config flag for public project feature 2024-03-09 20:31:32 +00:00
Christoph Ritzer 86983f50d4 fix(migration): Trello checklists (#2140)
Trello checklists are now properly converted to html checklists and put into the description.

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/vikunja#2140
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Christoph Ritzer <chris@cloumail.at>
Co-committed-by: Christoph Ritzer <chris@cloumail.at>
2024-03-09 09:01:02 +00:00
23 changed files with 335 additions and 25 deletions

View File

@ -62,6 +62,9 @@ service:
allowiconchanges: true
# Allow using a custom logo via external URL.
customlogourl: ''
# Enables the public team feature. If enabled, it is possible to configure teams to be public, which makes them
# discoverable when sharing a project, therefore not only showing teams the user is member of.
enablepublicteams: false
sentry:
# If set to true, enables anonymous error tracking of api errors via Sentry. This allows us to gather more

View File

@ -346,6 +346,17 @@ Full path: `service.customlogourl`
Environment path: `VIKUNJA_SERVICE_CUSTOMLOGOURL`
### enablepublicteams
discoverable when sharing a project, therefore not only showing teams the user is member of.
Default: `false`
Full path: `service.enablepublicteams`
Environment path: `VIKUNJA_SERVICE_ENABLEPUBLICTEAMS`
---
## sentry

View File

@ -95,7 +95,7 @@ It depends on the provider being used as well as the preferences of the administ
Typically you'd want to request an additional scope (e.g. `vikunja_scope`) which then triggers the identity provider to add the claim.
If the `vikunja_groups` is part of the **ID token**, Vikunja will start the procedure and import teams and team memberships.
The claim structure expexted by Vikunja is as follows:
The minimal claim structure expected by Vikunja is as follows:
```json
{
@ -112,6 +112,21 @@ The claim structure expexted by Vikunja is as follows:
}
```
It also also possible to pass the description and isPublic flag as optional parameter. If not present, the description will be empty and project visibility defaults to false.
```json
{
"vikunja_groups": [
{
"name": "team 3",
"oidcID": 33349,
"description": "My Team Description",
"isPublic": true
},
]
}
```
For each team, you need to define a team `name` and an `oidcID`, where the `oidcID` can be any string with a length of less than 250 characters.
The `oidcID` is used to uniquely identify the team, so please make sure to keep this unique.

View File

@ -172,6 +172,7 @@ import Multiselect from '@/components/input/multiselect.vue'
import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
// FIXME: I think this whole thing can now only manage user/team sharing for projects? Maybe remove a little generalization?
@ -210,8 +211,8 @@ const selectedRight = ref({})
const sharables = ref([])
const showDeleteModal = ref(false)
const authStore = useAuthStore()
const configStore = useConfigStore()
const userInfo = computed(() => authStore.info)
function createShareTypeNameComputed(count: number) {
@ -360,7 +361,15 @@ async function find(query: string) {
found.value = []
return
}
const results = await searchService.getAll({}, {s: query})
// Include public teams here if we are sharing with teams and its enabled in the config
let results = []
if (props.shareType === 'team' && configStore.publicTeamsEnabled) {
results = await searchService.getAll({}, {s: query, includePublic: true})
} else {
results = await searchService.getAll({}, {s: query})
}
found.value = results
.filter(m => {
if(props.shareType === 'user' && m.id === currentUserId.value) {

View File

@ -920,7 +920,9 @@
"description": "Description",
"descriptionPlaceholder": "Describe the team here, hit '/' for more options…",
"admin": "Admin",
"member": "Member"
"member": "Member",
"isPublic": "Visibility",
"isPublicDescription": "Make team publicly discoverable"
}
},
"keyboardShortcuts": {

View File

@ -10,6 +10,7 @@ export interface ITeam extends IAbstract {
members: ITeamMember[]
right: Right
oidcId: string
isPublic: boolean
createdBy: IUser
created: Date

View File

@ -14,6 +14,7 @@ export default class TeamModel extends AbstractModel<ITeam> implements ITeam {
members: ITeamMember[] = []
right: Right = RIGHTS.READ
oidcId = ''
isPublic: boolean = false
createdBy: IUser = {} // FIXME: seems wrong
created: Date = null

View File

@ -37,6 +37,7 @@ export interface ConfigState {
providers: IProvider[],
},
},
publicTeamsEnabled: boolean,
}
export const useConfigStore = defineStore('config', () => {
@ -70,6 +71,7 @@ export const useConfigStore = defineStore('config', () => {
providers: [],
},
},
publicTeamsEnabled: false,
})
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)

View File

@ -33,6 +33,27 @@
>
{{ $t('team.attributes.nameRequired') }}
</p>
<div
v-if="configStore.publicTeamsEnabled"
class="field"
>
<label
class="label"
for="teamIsPublic"
>{{ $t('team.attributes.isPublic') }}</label>
<div
class="control is-expanded"
:class="{ 'is-loading': teamService.loading }"
>
<Fancycheckbox
v-model="team.isPublic"
:disabled="teamMemberService.loading || undefined"
:class="{ 'disabled': teamService.loading }"
>
{{ $t('team.attributes.isPublicDescription') }}
</Fancycheckbox>
</div>
</div>
<div class="field">
<label
class="label"
@ -241,6 +262,7 @@ import {useI18n} from 'vue-i18n'
import {useRoute, useRouter} from 'vue-router'
import Editor from '@/components/input/AsyncEditor'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import Multiselect from '@/components/input/multiselect.vue'
import User from '@/components/misc/user.vue'
@ -253,12 +275,14 @@ import {RIGHTS as Rights} from '@/constants/rights'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import type {ITeam} from '@/modelTypes/ITeam'
import type {IUser} from '@/modelTypes/IUser'
import type {ITeamMember} from '@/modelTypes/ITeamMember'
const authStore = useAuthStore()
const configStore = useConfigStore()
const route = useRoute()
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
@ -305,6 +329,8 @@ async function save() {
}
showErrorTeamnameRequired.value = false
console.log('team.value', team.value)
team.value = await teamService.value.update(team.value)
success({message: t('team.edit.success')})
}

View File

@ -25,6 +25,26 @@
>
</div>
</div>
<div
v-if="configStore.publicTeamsEnabled"
class="field"
>
<label
class="label"
for="teamIsPublic"
>{{ $t('team.attributes.isPublic') }}</label>
<div
class="control is-expanded"
:class="{ 'is-loading': teamService.loading }"
>
<Fancycheckbox
v-model="team.isPublic"
:class="{ 'disabled': teamService.loading }"
>
{{ $t('team.attributes.isPublicDescription') }}
</Fancycheckbox>
</div>
</div>
<p
v-if="showError && team.name === ''"
class="help is-danger"
@ -46,11 +66,14 @@ import TeamModel from '@/models/team'
import TeamService from '@/services/team'
import CreateEdit from '@/components/misc/create-edit.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {useTitle} from '@/composables/useTitle'
import {useRouter} from 'vue-router'
import {success} from '@/message'
import {useConfigStore} from '@/stores/config'
const {t} = useI18n()
const title = computed(() => t('team.create.title'))
useTitle(title)
@ -60,6 +83,8 @@ const teamService = shallowReactive(new TeamService())
const team = reactive(new TeamModel())
const showError = ref(false)
const configStore = useConfigStore()
async function newTeam() {
if (team.name === '') {
showError.value = true

View File

@ -64,6 +64,7 @@ const (
ServiceMaxAvatarSize Key = `service.maxavatarsize`
ServiceAllowIconChanges Key = `service.allowiconchanges`
ServiceCustomLogoURL Key = `service.customlogourl`
ServiceEnablePublicTeams Key = `service.enablepublicteams`
SentryEnabled Key = `sentry.enabled`
SentryDsn Key = `sentry.dsn`
@ -312,6 +313,7 @@ func InitDefaultConfig() {
ServiceMaxAvatarSize.setDefault(1024)
ServiceDemoMode.setDefault(false)
ServiceAllowIconChanges.setDefault(true)
ServiceEnablePublicTeams.setDefault(false)
// Sentry
SentryDsn.setDefault("https://440eedc957d545a795c17bbaf477497c@o1047380.ingest.sentry.io/4504254983634944")

View File

@ -29,8 +29,16 @@
- id: 13
name: testteam13
created_by_id: 7
is_public: true
- id: 14
name: testteam14
created_by_id: 7
oidc_id: 14
issuer: "https://some.issuer"
issuer: "https://some.issuer"
- id: 15
name: testteam15
created_by_id: 7
oidc_id: 15
issuer: "https://some.issuer"
is_public: true
description: "This is a public team"

View File

@ -0,0 +1,43 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package migration
import (
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type teams20240309111148 struct {
IsPublic bool `xorm:"not null default 0" json:"is_public"`
}
func (teams20240309111148) TableName() string {
return "teams"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20240309111148",
Description: "Add IsPublic field to teams table to control discoverability of teams.",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(teams20240309111148{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -19,6 +19,7 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
@ -53,6 +54,12 @@ type Team struct {
// A timestamp when this relation was last updated. You cannot change this value.
Updated time.Time `xorm:"updated" json:"updated"`
// Defines wether the team should be publicly discoverable when sharing a project
IsPublic bool `xorm:"not null default 0" json:"is_public"`
// Query parameter controlling whether to include public projects or not
IncludePublic bool `xorm:"-" query:"include_public" json:"include_public"`
web.CRUDable `xorm:"-" json:"-"`
web.Rights `xorm:"-" json:"-"`
}
@ -100,6 +107,7 @@ type OIDCTeam struct {
Name string
OidcID string
Description string
IsPublic bool
}
// GetTeamByID gets a team by its ID
@ -287,11 +295,24 @@ func (t *Team) ReadAll(s *xorm.Session, a web.Auth, search string, page int, per
limit, start := getLimitFromPageIndex(page, perPage)
all := []*Team{}
query := s.Select("teams.*").
Table("teams").
Join("INNER", "team_members", "team_members.team_id = teams.id").
Where("team_members.user_id = ?", a.GetID()).
Where(db.ILIKE("teams.name", search))
// If public teams are enabled, we want to include them in the result
if config.ServiceEnablePublicTeams.GetBool() && t.IncludePublic {
query = query.Where(
builder.Or(
builder.Eq{"teams.is_public": true},
builder.Eq{"team_members.user_id": a.GetID()},
),
)
} else {
query = query.Where("team_members.user_id = ?", a.GetID())
}
if limit > 0 {
query = query.Limit(limit, start)
}
@ -398,7 +419,7 @@ func (t *Team) Update(s *xorm.Session, _ web.Auth) (err error) {
return
}
_, err = s.ID(t.ID).Update(t)
_, err = s.ID(t.ID).UseBool("is_public").Update(t)
if err != nil {
return
}

View File

@ -49,6 +49,7 @@ func TestTeam_Create(t *testing.T) {
"id": team.ID,
"name": "Testteam293",
"description": "Lorem Ispum",
"is_public": false,
}, false)
})
t.Run("empty name", func(t *testing.T) {
@ -61,6 +62,27 @@ func TestTeam_Create(t *testing.T) {
require.Error(t, err)
assert.True(t, IsErrTeamNameCannotBeEmpty(err))
})
t.Run("public", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
team := &Team{
Name: "Testteam293_Public",
Description: "Lorem Ispum",
IsPublic: true,
}
err := team.Create(s, doer)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "teams", map[string]interface{}{
"id": team.ID,
"name": "Testteam293_Public",
"description": "Lorem Ispum",
"is_public": true,
}, false)
})
}
func TestTeam_ReadOne(t *testing.T) {

View File

@ -298,14 +298,27 @@ func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (
var name string
var description string
var oidcID string
var IsPublic bool
// Read name
_, exists := team["name"]
if exists {
name = team["name"].(string)
}
// Read description
_, exists = team["description"]
if exists {
description = team["description"].(string)
}
// Read isPublic flag
_, exists = team["isPublic"]
if exists {
IsPublic = team["isPublic"].(bool)
}
// Read oidcID
_, exists = team["oidcID"]
if exists {
switch t := team["oidcID"].(type) {
@ -324,7 +337,7 @@ func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (
errs = append(errs, &user.ErrOpenIDCustomScopeMalformed{})
continue
}
teamData = append(teamData, &models.OIDCTeam{Name: name, OidcID: oidcID, Description: description})
teamData = append(teamData, &models.OIDCTeam{Name: name, OidcID: oidcID, Description: description, IsPublic: IsPublic})
}
return teamData, errs
}
@ -339,6 +352,7 @@ func CreateOIDCTeam(s *xorm.Session, teamData *models.OIDCTeam, u *user.User, is
Description: teamData.Description,
OidcID: teamData.OidcID,
Issuer: issuer,
IsPublic: teamData.IsPublic,
}
err = team.CreateNewTeam(s, u, false)
return team, err
@ -363,12 +377,24 @@ func GetOrCreateTeamsByOIDC(s *xorm.Session, teamData []*models.OIDCTeam, u *use
continue
}
// Compare the name and update if it changed
if team.Name != getOIDCTeamName(oidcTeam.Name) {
team.Name = getOIDCTeamName(oidcTeam.Name)
err = team.Update(s, u)
if err != nil {
return nil, err
}
}
// Compare the description and update if it changed
if team.Description != oidcTeam.Description {
team.Description = oidcTeam.Description
}
// Compare the isPublic flag and update if it changed
if team.IsPublic != oidcTeam.IsPublic {
team.IsPublic = oidcTeam.IsPublic
}
err = team.Update(s, u)
if err != nil {
return nil, err
}
log.Debugf("Team with oidc_id %v and name %v already exists.", team.OidcID, team.Name)

View File

@ -128,8 +128,41 @@ func TestGetOrCreateUser(t *testing.T) {
"email": cl.Email,
}, false)
db.AssertExists(t, "teams", map[string]interface{}{
"id": oidcTeams,
"name": team + " (OIDC)",
"id": oidcTeams,
"name": team + " (OIDC)",
"is_public": false,
}, false)
})
t.Run("Update IsPublic flag for existing team", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
team := "testteam15"
oidcID := "15"
cl := &claims{
Email: "other-email-address@some.service.com",
VikunjaGroups: []map[string]interface{}{
{"name": team, "oidcID": oidcID, "isPublic": true},
},
}
u, err := getOrCreateUser(s, cl, "https://some.service.com", "12345")
require.NoError(t, err)
teamData, errs := getTeamDataFromToken(cl.VikunjaGroups, nil)
for _, err := range errs {
require.NoError(t, err)
}
oidcTeams, err := AssignOrCreateUserToTeams(s, u, teamData, "https://some.issuer")
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
db.AssertExists(t, "teams", map[string]interface{}{
"id": oidcTeams,
"name": team + " (OIDC)",
"is_public": true,
}, false)
})

View File

@ -248,17 +248,17 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullV
// Checklists (as markdown in description)
for _, checklist := range card.Checklists {
task.Description += "\n\n## " + checklist.Name + "\n"
task.Description += "\n\n<h2> " + checklist.Name + "</h2>\n\n" + `<ul data-type="taskList">`
for _, item := range checklist.CheckItems {
task.Description += "\n* "
task.Description += "\n"
if item.State == "complete" {
task.Description += "[x]"
task.Description += `<li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>` + item.Name + `</p></div></li>`
} else {
task.Description += "[ ]"
task.Description += `<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>` + item.Name + `</p></div></li>`
}
task.Description += " " + item.Name
}
task.Description += "</ul>"
}
if len(card.Checklists) > 0 {
log.Debugf("[Trello Migration] Converted %d checklists from card %s", len(card.Checklists), card.ID)

View File

@ -249,15 +249,17 @@ func TestConvertTrelloToVikunja(t *testing.T) {
Title: "Test Card 2",
Description: `
## Checkproject 1
<h2> Checkproject 1</h2>
* [ ] Pending Task
* [x] Completed Task
<ul data-type="taskList">
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Pending Task</p></div></li>
<li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Completed Task</p></div></li></ul>
## Checkproject 2
<h2> Checkproject 2</h2>
* [ ] Pending Task
* [ ] Another Pending Task`,
<ul data-type="taskList">
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Pending Task</p></div></li>
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Another Pending Task</p></div></li></ul>`,
BucketID: 1,
KanbanPosition: 124,
},

View File

@ -51,6 +51,7 @@ type vikunjaInfos struct {
TaskCommentsEnabled bool `json:"task_comments_enabled"`
DemoModeEnabled bool `json:"demo_mode_enabled"`
WebhooksEnabled bool `json:"webhooks_enabled"`
PublicTeamsEnabled bool `json:"public_teams_enabled"`
}
type authInfo struct {
@ -95,6 +96,7 @@ func Info(c echo.Context) error {
TaskCommentsEnabled: config.ServiceEnableTaskComments.GetBool(),
DemoModeEnabled: config.ServiceDemoMode.GetBool(),
WebhooksEnabled: config.WebhooksEnabled.GetBool(),
PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(),
AvailableMigrators: []string{
(&vikunja_file.FileMigrator{}).Name(),
(&ticktick.Migrator{}).Name(),

View File

@ -8287,6 +8287,14 @@ const docTemplate = `{
"description": "The unique, numeric id of this team.",
"type": "integer"
},
"include_public": {
"description": "Query parameter controlling whether to include public projects or not",
"type": "boolean"
},
"is_public": {
"description": "Defines wether the team should be publicly discoverable when sharing a project",
"type": "boolean"
},
"members": {
"description": "An array of all members in this team.",
"type": "array",
@ -8422,6 +8430,14 @@ const docTemplate = `{
"description": "The unique, numeric id of this team.",
"type": "integer"
},
"include_public": {
"description": "Query parameter controlling whether to include public projects or not",
"type": "boolean"
},
"is_public": {
"description": "Defines wether the team should be publicly discoverable when sharing a project",
"type": "boolean"
},
"members": {
"description": "An array of all members in this team.",
"type": "array",
@ -8944,6 +8960,9 @@ const docTemplate = `{
"motd": {
"type": "string"
},
"public_teams_enabled": {
"type": "boolean"
},
"registration_enabled": {
"type": "boolean"
},

View File

@ -8279,6 +8279,14 @@
"description": "The unique, numeric id of this team.",
"type": "integer"
},
"include_public": {
"description": "Query parameter controlling whether to include public projects or not",
"type": "boolean"
},
"is_public": {
"description": "Defines wether the team should be publicly discoverable when sharing a project",
"type": "boolean"
},
"members": {
"description": "An array of all members in this team.",
"type": "array",
@ -8414,6 +8422,14 @@
"description": "The unique, numeric id of this team.",
"type": "integer"
},
"include_public": {
"description": "Query parameter controlling whether to include public projects or not",
"type": "boolean"
},
"is_public": {
"description": "Defines wether the team should be publicly discoverable when sharing a project",
"type": "boolean"
},
"members": {
"description": "An array of all members in this team.",
"type": "array",
@ -8936,6 +8952,9 @@
"motd": {
"type": "string"
},
"public_teams_enabled": {
"type": "boolean"
},
"registration_enabled": {
"type": "boolean"
},

View File

@ -894,6 +894,14 @@ definitions:
id:
description: The unique, numeric id of this team.
type: integer
include_public:
description: Query parameter controlling whether to include public projects
or not
type: boolean
is_public:
description: Defines wether the team should be publicly discoverable when
sharing a project
type: boolean
members:
description: An array of all members in this team.
items:
@ -1001,6 +1009,14 @@ definitions:
id:
description: The unique, numeric id of this team.
type: integer
include_public:
description: Query parameter controlling whether to include public projects
or not
type: boolean
is_public:
description: Defines wether the team should be publicly discoverable when
sharing a project
type: boolean
members:
description: An array of all members in this team.
items:
@ -1386,6 +1402,8 @@ definitions:
type: string
motd:
type: string
public_teams_enabled:
type: boolean
registration_enabled:
type: boolean
task_attachments_enabled: