1
0
Fork 0
mirror of https://github.com/documize/community.git synced 2025-07-24 07:39:43 +02:00

Merge pull request #136 from documize/user-groups

User groups
This commit is contained in:
Saul S 2018-03-08 12:11:59 +00:00 committed by GitHub
commit 1c4a4424e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
114 changed files with 6324 additions and 2192 deletions

View file

@ -1,4 +1,3 @@
gui/public/tinymce/**
gui/public/tinymce/
gui/public/tinymce
gui/public/tinymce

8
Gopkg.lock generated
View file

@ -149,9 +149,15 @@
revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a"
version = "v1.0.0"
[[projects]]
branch = "v3"
name = "gopkg.in/alexcesaro/quotedprintable.v3"
packages = ["."]
revision = "2caba252f4dc53eaf6b553000885530023f54623"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "98ebdf85207168f55b51629c6c21eb1459881fa58c280503054994887cbde045"
inputs-digest = "56eea54cf0b9e18b3e456199ee01aedeeb07b55073ca3d10345e0c178689aee2"
solver-name = "gps-cdcl"
solver-version = 1

View file

@ -52,7 +52,7 @@ Space view.
## Latest version
Community edition: v1.57.3
Community edition: v1.58.0
## OS support

View file

@ -165,7 +165,6 @@ func setupAccount(rt *env.Runtime, completion onboardRequest, serial string) (er
labelID := uniqueid.Generate()
sql = fmt.Sprintf("insert into label (refid, orgid, label, type, userid) values (\"%s\", \"%s\", \"My Project\", 2, \"%s\")", labelID, orgID, userID)
_, err = runSQL(rt, sql)
if err != nil {
rt.Log.Error("insert into label failed", err)
}
@ -180,6 +179,45 @@ func setupAccount(rt *env.Runtime, completion onboardRequest, serial string) (er
}
}
// Create some user groups
groupDevID := uniqueid.Generate()
sql = fmt.Sprintf("INSERT INTO role (refid, orgid, role, purpose) VALUES (\"%s\", \"%s\", \"Technology\", \"On-site and remote development teams\")", groupDevID, orgID)
_, err = runSQL(rt, sql)
if err != nil {
rt.Log.Error("insert into role failed", err)
}
groupProjectID := uniqueid.Generate()
sql = fmt.Sprintf("INSERT INTO role (refid, orgid, role, purpose) VALUES (\"%s\", \"%s\", \"Project Management\", \"HQ project management\")", groupProjectID, orgID)
_, err = runSQL(rt, sql)
if err != nil {
rt.Log.Error("insert into role failed", err)
}
groupBackofficeID := uniqueid.Generate()
sql = fmt.Sprintf("INSERT INTO role (refid, orgid, role, purpose) VALUES (\"%s\", \"%s\", \"Back Office\", \"Non-IT and PMO personnel\")", groupBackofficeID, orgID)
_, err = runSQL(rt, sql)
if err != nil {
rt.Log.Error("insert into role failed", err)
}
// Join some groups
sql = fmt.Sprintf("INSERT INTO rolemember (orgid, roleid, userid) VALUES (\"%s\", \"%s\", \"%s\")", orgID, groupDevID, userID)
_, err = runSQL(rt, sql)
if err != nil {
rt.Log.Error("insert into rolemember failed", err)
}
sql = fmt.Sprintf("INSERT INTO rolemember (orgid, roleid, userid) VALUES (\"%s\", \"%s\", \"%s\")", orgID, groupProjectID, userID)
_, err = runSQL(rt, sql)
if err != nil {
rt.Log.Error("insert into rolemember failed", err)
}
sql = fmt.Sprintf("INSERT INTO rolemember (orgid, roleid, userid) VALUES (\"%s\", \"%s\", \"%s\")", orgID, groupBackofficeID, userID)
_, err = runSQL(rt, sql)
if err != nil {
rt.Log.Error("insert into rolemember failed", err)
}
return
}

View file

@ -0,0 +1,9 @@
/* community edition */
-- role and role membership
ALTER TABLE role ADD COLUMN `purpose` VARCHAR(100) DEFAULT '' NOT NULL AFTER `role`;
ALTER TABLE role ADD COLUMN `revised` TIMESTAMP DEFAULT CURRENT_TIMESTAMP AFTER `created`;
CREATE INDEX idx_role_1 ON role(orgid);
-- deprecations
DROP TABLE IF EXISTS `labelrole`;

65
core/mail/CHANGELOG.md Normal file
View file

@ -0,0 +1,65 @@
# Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [2.2.0] - 2018-03-01
### Added
- #20: Adds `Message.SetBoundary` to allow specifying a custom MIME boundary.
- #22: Adds `Message.SetBodyWriter` to make it easy to use text/template and
html/template for message bodies. Contributed by Quantcast.
- #25: Adds `Dialer.StartTLSPolicy` so that `MandatoryStartTLS` can be required,
or `NoStartTLS` can disable it. Contributed by Quantcast.
## [2.1.0] - 2017-12-14
### Added
- go-gomail#40: Adds `Dialer.LocalName` field to allow specifying the hostname
sent with SMTP's HELO command.
- go-gomail#47: `Message.SetBody`, `Message.AddAlternative`, and
`Message.AddAlternativeWriter` allow specifying the encoding of message parts.
- `Dialer.Dial`'s returned `SendCloser` automatically redials after a timeout.
- go-gomail#55, go-gomail#56: Adds `Rename` to allow specifying filename
of an attachment.
- go-gomail#100: Exports `NetDialTimeout` to allow setting a custom dialer.
- go-gomail#70: Adds `Dialer.Timeout` field to allow specifying a timeout for
dials, reads, and writes.
### Changed
- go-gomail#52: `Dialer.Dial` automatically uses CRAM-MD5 when available.
- `Dialer.Dial` specifies a default timeout of 10 seconds.
- Gomail is forked from <https://github.com/go-gomail/gomail/> to
<https://github.com/go-mail/mail/>.
### Deprecated
- go-gomail#52: `NewPlainDialer` is deprecated in favor of `NewDialer`.
### Fixed
- go-gomail#41, go-gomail#42: Fixes a panic when a `Message` contains a
nil header.
- go-gomail#44: Fixes `AddAlternativeWriter` replacing the message body instead
of adding a body part.
- go-gomail#53: Folds long header lines for RFC 2047 compliance.
- go-gomail#54: Fixes `Message.FormatAddress` when name is blank.
## [2.0.0] - 2015-09-02
- Mailer has been removed. It has been replaced by Dialer and Sender.
- `File` type and the `CreateFile` and `OpenFile` functions have been removed.
- `Message.Attach` and `Message.Embed` have a new signature.
- `Message.GetBodyWriter` has been removed. Use `Message.AddAlternativeWriter`
instead.
- `Message.Export` has been removed. `Message.WriteTo` can be used instead.
- `Message.DelHeader` has been removed.
- The `Bcc` header field is no longer sent. It is far more simpler and
efficient: the same message is sent to all recipients instead of sending a
different email to each Bcc address.
- LoginAuth has been removed. `NewPlainDialer` now implements the LOGIN
authentication mechanism when needed.
- Go 1.2 is now required instead of Go 1.3. No external dependency are used when
using Go 1.5.

20
core/mail/CONTRIBUTING.md Normal file
View file

@ -0,0 +1,20 @@
Thank you for contributing to Gomail! Here are a few guidelines:
## Bugs
If you think you found a bug, create an issue and supply the minimum amount
of code triggering the bug so it can be reproduced.
## Fixing a bug
If you want to fix a bug, you can send a pull request. It should contains a
new test or update an existing one to cover that bug.
## New feature proposal
If you think Gomail lacks a feature, you can open an issue or send a pull
request. I want to keep Gomail code and API as simple as possible so please
describe your needs so we can discuss whether this feature should be added to
Gomail or not.

20
core/mail/LICENSE Normal file
View file

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2014 Alexandre Cesaro
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

129
core/mail/README.md Normal file
View file

@ -0,0 +1,129 @@
# Gomail
[![Build Status](https://travis-ci.org/go-mail/mail.svg?branch=master)](https://travis-ci.org/go-mail/mail) [![Code Coverage](http://gocover.io/_badge/github.com/go-mail/mail)](http://gocover.io/github.com/go-mail/mail) [![Documentation](https://godoc.org/github.com/go-mail/mail?status.svg)](https://godoc.org/github.com/go-mail/mail)
This is an actively maintained fork of [Gomail][1] and includes fixes and
improvements for a number of outstanding issues. The current progress is
as follows:
- [x] Timeouts and retries can be specified outside of the 10 second default.
- [x] Proxying is supported through specifying a custom [NetDialTimeout][2].
- [ ] Filenames are properly encoded for non-ASCII characters.
- [ ] Email addresses are properly encoded for non-ASCII characters.
- [ ] Embedded files and attachments are tested for their existence.
- [ ] An `io.Reader` can be supplied when embedding and attaching files.
See [Transitioning Existing Codebases][3] for more information on switching.
[1]: https://github.com/go-gomail/gomail
[2]: https://godoc.org/gopkg.in/mail.v2#NetDialTimeout
[3]: #transitioning-existing-codebases
## Introduction
Gomail is a simple and efficient package to send emails. It is well tested and
documented.
Gomail can only send emails using an SMTP server. But the API is flexible and it
is easy to implement other methods for sending emails using a local Postfix, an
API, etc.
It requires Go 1.2 or newer. With Go 1.5, no external dependencies are used.
## Features
Gomail supports:
- Attachments
- Embedded images
- HTML and text templates
- Automatic encoding of special characters
- SSL and TLS
- Sending multiple emails with the same SMTP connection
## Documentation
https://godoc.org/github.com/go-mail/mail
## Download
If you're already using a dependency manager, like [dep][dep], use the following
import path:
```
github.com/go-mail/mail
```
If you *aren't* using vendoring, `go get` the [Gopkg.in](http://gopkg.in)
import path:
```
gopkg.in/mail.v2
```
[dep]: https://github.com/golang/dep#readme
## Examples
See the [examples in the documentation](https://godoc.org/github.com/go-mail/mail#example-package).
## FAQ
### x509: certificate signed by unknown authority
If you get this error it means the certificate used by the SMTP server is not
considered valid by the client running Gomail. As a quick workaround you can
bypass the verification of the server's certificate chain and host name by using
`SetTLSConfig`:
```go
package main
import (
"crypto/tls"
"gopkg.in/mail.v2"
)
func main() {
d := mail.NewDialer("smtp.example.com", 587, "user", "123456")
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
// Send emails using d.
}
```
Note, however, that this is insecure and should not be used in production.
### Transitioning Existing Codebases
If you're already using the original Gomail, switching is as easy as updating
the import line to:
```
import gomail "gopkg.in/mail.v2"
```
## Contribute
Contributions are more than welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for
more info.
## Change log
See [CHANGELOG.md](CHANGELOG.md).
## License
[MIT](LICENSE)
## Support & Contact
You can ask questions on the [Gomail
thread](https://groups.google.com/d/topic/golang-nuts/jMxZHzvvEVg/discussion)
in the Go mailing-list.

49
core/mail/auth.go Normal file
View file

@ -0,0 +1,49 @@
package mail
import (
"bytes"
"errors"
"fmt"
"net/smtp"
)
// loginAuth is an smtp.Auth that implements the LOGIN authentication mechanism.
type loginAuth struct {
username string
password string
host string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if !server.TLS {
advertised := false
for _, mechanism := range server.Auth {
if mechanism == "LOGIN" {
advertised = true
break
}
}
if !advertised {
return "", nil, errors.New("gomail: unencrypted connection")
}
}
if server.Name != a.host {
return "", nil, errors.New("gomail: wrong host name")
}
return "LOGIN", nil, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
switch {
case bytes.Equal(fromServer, []byte("Username:")):
return []byte(a.username), nil
case bytes.Equal(fromServer, []byte("Password:")):
return []byte(a.password), nil
default:
return nil, fmt.Errorf("gomail: unexpected server challenge: %s", fromServer)
}
}

6
core/mail/doc.go Normal file
View file

@ -0,0 +1,6 @@
// Package gomail provides a simple interface to compose emails and to mail them
// efficiently.
//
// More info on Github: https://github.com/go-mail/mail
//
package mail

334
core/mail/message.go Normal file
View file

@ -0,0 +1,334 @@
package mail
import (
"bytes"
"io"
"os"
"path/filepath"
"time"
)
// Message represents an email.
type Message struct {
header header
parts []*part
attachments []*file
embedded []*file
charset string
encoding Encoding
hEncoder mimeEncoder
buf bytes.Buffer
boundary string
}
type header map[string][]string
type part struct {
contentType string
copier func(io.Writer) error
encoding Encoding
}
// NewMessage creates a new message. It uses UTF-8 and quoted-printable encoding
// by default.
func NewMessage(settings ...MessageSetting) *Message {
m := &Message{
header: make(header),
charset: "UTF-8",
encoding: QuotedPrintable,
}
m.applySettings(settings)
if m.encoding == Base64 {
m.hEncoder = bEncoding
} else {
m.hEncoder = qEncoding
}
return m
}
// Reset resets the message so it can be reused. The message keeps its previous
// settings so it is in the same state that after a call to NewMessage.
func (m *Message) Reset() {
for k := range m.header {
delete(m.header, k)
}
m.parts = nil
m.attachments = nil
m.embedded = nil
}
func (m *Message) applySettings(settings []MessageSetting) {
for _, s := range settings {
s(m)
}
}
// A MessageSetting can be used as an argument in NewMessage to configure an
// email.
type MessageSetting func(m *Message)
// SetCharset is a message setting to set the charset of the email.
func SetCharset(charset string) MessageSetting {
return func(m *Message) {
m.charset = charset
}
}
// SetEncoding is a message setting to set the encoding of the email.
func SetEncoding(enc Encoding) MessageSetting {
return func(m *Message) {
m.encoding = enc
}
}
// Encoding represents a MIME encoding scheme like quoted-printable or base64.
type Encoding string
const (
// QuotedPrintable represents the quoted-printable encoding as defined in
// RFC 2045.
QuotedPrintable Encoding = "quoted-printable"
// Base64 represents the base64 encoding as defined in RFC 2045.
Base64 Encoding = "base64"
// Unencoded can be used to avoid encoding the body of an email. The headers
// will still be encoded using quoted-printable encoding.
Unencoded Encoding = "8bit"
)
// SetBoundary sets a custom multipart boundary.
func (m *Message) SetBoundary(boundary string) {
m.boundary = boundary
}
// SetHeader sets a value to the given header field.
func (m *Message) SetHeader(field string, value ...string) {
m.encodeHeader(value)
m.header[field] = value
}
func (m *Message) encodeHeader(values []string) {
for i := range values {
values[i] = m.encodeString(values[i])
}
}
func (m *Message) encodeString(value string) string {
return m.hEncoder.Encode(m.charset, value)
}
// SetHeaders sets the message headers.
func (m *Message) SetHeaders(h map[string][]string) {
for k, v := range h {
m.SetHeader(k, v...)
}
}
// SetAddressHeader sets an address to the given header field.
func (m *Message) SetAddressHeader(field, address, name string) {
m.header[field] = []string{m.FormatAddress(address, name)}
}
// FormatAddress formats an address and a name as a valid RFC 5322 address.
func (m *Message) FormatAddress(address, name string) string {
if name == "" {
return address
}
enc := m.encodeString(name)
if enc == name {
m.buf.WriteByte('"')
for i := 0; i < len(name); i++ {
b := name[i]
if b == '\\' || b == '"' {
m.buf.WriteByte('\\')
}
m.buf.WriteByte(b)
}
m.buf.WriteByte('"')
} else if hasSpecials(name) {
m.buf.WriteString(bEncoding.Encode(m.charset, name))
} else {
m.buf.WriteString(enc)
}
m.buf.WriteString(" <")
m.buf.WriteString(address)
m.buf.WriteByte('>')
addr := m.buf.String()
m.buf.Reset()
return addr
}
func hasSpecials(text string) bool {
for i := 0; i < len(text); i++ {
switch c := text[i]; c {
case '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"':
return true
}
}
return false
}
// SetDateHeader sets a date to the given header field.
func (m *Message) SetDateHeader(field string, date time.Time) {
m.header[field] = []string{m.FormatDate(date)}
}
// FormatDate formats a date as a valid RFC 5322 date.
func (m *Message) FormatDate(date time.Time) string {
return date.Format(time.RFC1123Z)
}
// GetHeader gets a header field.
func (m *Message) GetHeader(field string) []string {
return m.header[field]
}
// SetBody sets the body of the message. It replaces any content previously set
// by SetBody, SetBodyWriter, AddAlternative or AddAlternativeWriter.
func (m *Message) SetBody(contentType, body string, settings ...PartSetting) {
m.SetBodyWriter(contentType, newCopier(body), settings...)
}
// SetBodyWriter sets the body of the message. It can be useful with the
// text/template or html/template packages.
func (m *Message) SetBodyWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) {
m.parts = []*part{m.newPart(contentType, f, settings)}
}
// AddAlternative adds an alternative part to the message.
//
// It is commonly used to send HTML emails that default to the plain text
// version for backward compatibility. AddAlternative appends the new part to
// the end of the message. So the plain text part should be added before the
// HTML part. See http://en.wikipedia.org/wiki/MIME#Alternative
func (m *Message) AddAlternative(contentType, body string, settings ...PartSetting) {
m.AddAlternativeWriter(contentType, newCopier(body), settings...)
}
func newCopier(s string) func(io.Writer) error {
return func(w io.Writer) error {
_, err := io.WriteString(w, s)
return err
}
}
// AddAlternativeWriter adds an alternative part to the message. It can be
// useful with the text/template or html/template packages.
func (m *Message) AddAlternativeWriter(contentType string, f func(io.Writer) error, settings ...PartSetting) {
m.parts = append(m.parts, m.newPart(contentType, f, settings))
}
func (m *Message) newPart(contentType string, f func(io.Writer) error, settings []PartSetting) *part {
p := &part{
contentType: contentType,
copier: f,
encoding: m.encoding,
}
for _, s := range settings {
s(p)
}
return p
}
// A PartSetting can be used as an argument in Message.SetBody,
// Message.SetBodyWriter, Message.AddAlternative or Message.AddAlternativeWriter
// to configure the part added to a message.
type PartSetting func(*part)
// SetPartEncoding sets the encoding of the part added to the message. By
// default, parts use the same encoding than the message.
func SetPartEncoding(e Encoding) PartSetting {
return PartSetting(func(p *part) {
p.encoding = e
})
}
type file struct {
Name string
Header map[string][]string
CopyFunc func(w io.Writer) error
}
func (f *file) setHeader(field, value string) {
f.Header[field] = []string{value}
}
// A FileSetting can be used as an argument in Message.Attach or Message.Embed.
type FileSetting func(*file)
// SetHeader is a file setting to set the MIME header of the message part that
// contains the file content.
//
// Mandatory headers are automatically added if they are not set when sending
// the email.
func SetHeader(h map[string][]string) FileSetting {
return func(f *file) {
for k, v := range h {
f.Header[k] = v
}
}
}
// Rename is a file setting to set the name of the attachment if the name is
// different than the filename on disk.
func Rename(name string) FileSetting {
return func(f *file) {
f.Name = name
}
}
// SetCopyFunc is a file setting to replace the function that runs when the
// message is sent. It should copy the content of the file to the io.Writer.
//
// The default copy function opens the file with the given filename, and copy
// its content to the io.Writer.
func SetCopyFunc(f func(io.Writer) error) FileSetting {
return func(fi *file) {
fi.CopyFunc = f
}
}
func (m *Message) appendFile(list []*file, name string, settings []FileSetting) []*file {
f := &file{
Name: filepath.Base(name),
Header: make(map[string][]string),
CopyFunc: func(w io.Writer) error {
h, err := os.Open(name)
if err != nil {
return err
}
if _, err := io.Copy(w, h); err != nil {
h.Close()
return err
}
return h.Close()
},
}
for _, s := range settings {
s(f)
}
if list == nil {
return []*file{f}
}
return append(list, f)
}
// Attach attaches the files to the email.
func (m *Message) Attach(filename string, settings ...FileSetting) {
m.attachments = m.appendFile(m.attachments, filename, settings)
}
// Embed embeds the images to the email.
func (m *Message) Embed(filename string, settings ...FileSetting) {
m.embedded = m.appendFile(m.embedded, filename, settings)
}

21
core/mail/mime.go Normal file
View file

@ -0,0 +1,21 @@
// +build go1.5
package mail
import (
"mime"
"mime/quotedprintable"
"strings"
)
var newQPWriter = quotedprintable.NewWriter
type mimeEncoder struct {
mime.WordEncoder
}
var (
bEncoding = mimeEncoder{mime.BEncoding}
qEncoding = mimeEncoder{mime.QEncoding}
lastIndexByte = strings.LastIndexByte
)

25
core/mail/mime_go14.go Normal file
View file

@ -0,0 +1,25 @@
// +build !go1.5
package mail
import "gopkg.in/alexcesaro/quotedprintable.v3"
var newQPWriter = quotedprintable.NewWriter
type mimeEncoder struct {
quotedprintable.WordEncoder
}
var (
bEncoding = mimeEncoder{quotedprintable.BEncoding}
qEncoding = mimeEncoder{quotedprintable.QEncoding}
lastIndexByte = func(s string, c byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == c {
return i
}
}
return -1
}
)

116
core/mail/send.go Normal file
View file

@ -0,0 +1,116 @@
package mail
import (
"errors"
"fmt"
"io"
stdmail "net/mail"
)
// Sender is the interface that wraps the Send method.
//
// Send sends an email to the given addresses.
type Sender interface {
Send(from string, to []string, msg io.WriterTo) error
}
// SendCloser is the interface that groups the Send and Close methods.
type SendCloser interface {
Sender
Close() error
}
// A SendFunc is a function that sends emails to the given addresses.
//
// The SendFunc type is an adapter to allow the use of ordinary functions as
// email senders. If f is a function with the appropriate signature, SendFunc(f)
// is a Sender object that calls f.
type SendFunc func(from string, to []string, msg io.WriterTo) error
// Send calls f(from, to, msg).
func (f SendFunc) Send(from string, to []string, msg io.WriterTo) error {
return f(from, to, msg)
}
// Send sends emails using the given Sender.
func Send(s Sender, msg ...*Message) error {
for i, m := range msg {
if err := send(s, m); err != nil {
return fmt.Errorf("gomail: could not send email %d: %v", i+1, err)
}
}
return nil
}
func send(s Sender, m *Message) error {
from, err := m.getFrom()
if err != nil {
return err
}
to, err := m.getRecipients()
if err != nil {
return err
}
if err := s.Send(from, to, m); err != nil {
return err
}
return nil
}
func (m *Message) getFrom() (string, error) {
from := m.header["Sender"]
if len(from) == 0 {
from = m.header["From"]
if len(from) == 0 {
return "", errors.New(`gomail: invalid message, "From" field is absent`)
}
}
return parseAddress(from[0])
}
func (m *Message) getRecipients() ([]string, error) {
n := 0
for _, field := range []string{"To", "Cc", "Bcc"} {
if addresses, ok := m.header[field]; ok {
n += len(addresses)
}
}
list := make([]string, 0, n)
for _, field := range []string{"To", "Cc", "Bcc"} {
if addresses, ok := m.header[field]; ok {
for _, a := range addresses {
addr, err := parseAddress(a)
if err != nil {
return nil, err
}
list = addAddress(list, addr)
}
}
}
return list, nil
}
func addAddress(list []string, addr string) []string {
for _, a := range list {
if addr == a {
return list
}
}
return append(list, addr)
}
func parseAddress(field string) (string, error) {
addr, err := stdmail.ParseAddress(field)
if err != nil {
return "", fmt.Errorf("gomail: invalid address %q: %v", field, err)
}
return addr.Address, nil
}

292
core/mail/smtp.go Normal file
View file

@ -0,0 +1,292 @@
package mail
import (
"crypto/tls"
"fmt"
"io"
"net"
"net/smtp"
"strings"
"time"
)
// A Dialer is a dialer to an SMTP server.
type Dialer struct {
// Host represents the host of the SMTP server.
Host string
// Port represents the port of the SMTP server.
Port int
// Username is the username to use to authenticate to the SMTP server.
Username string
// Password is the password to use to authenticate to the SMTP server.
Password string
// Auth represents the authentication mechanism used to authenticate to the
// SMTP server.
Auth smtp.Auth
// SSL defines whether an SSL connection is used. It should be false in
// most cases since the authentication mechanism should use the STARTTLS
// extension instead.
SSL bool
// TLSConfig represents the TLS configuration used for the TLS (when the
// STARTTLS extension is used) or SSL connection.
TLSConfig *tls.Config
// StartTLSPolicy represents the TLS security level required to
// communicate with the SMTP server.
//
// This defaults to OpportunisticStartTLS for backwards compatibility,
// but we recommend MandatoryStartTLS for all modern SMTP servers.
//
// This option has no effect if SSL is set to true.
StartTLSPolicy StartTLSPolicy
// LocalName is the hostname sent to the SMTP server with the HELO command.
// By default, "localhost" is sent.
LocalName string
// Timeout to use for read/write operations. Defaults to 10 seconds, can
// be set to 0 to disable timeouts.
Timeout time.Duration
// Whether we should retry mailing if the connection returned an error,
// defaults to true.
RetryFailure bool
}
// NewDialer returns a new SMTP Dialer. The given parameters are used to connect
// to the SMTP server.
func NewDialer(host string, port int, username, password string) *Dialer {
return &Dialer{
Host: host,
Port: port,
Username: username,
Password: password,
SSL: port == 465,
Timeout: 10 * time.Second,
RetryFailure: true,
}
}
// NewPlainDialer returns a new SMTP Dialer. The given parameters are used to
// connect to the SMTP server.
//
// Deprecated: Use NewDialer instead.
func NewPlainDialer(host string, port int, username, password string) *Dialer {
return NewDialer(host, port, username, password)
}
// NetDialTimeout specifies the DialTimeout function to establish a connection
// to the SMTP server. This can be used to override dialing in the case that a
// proxy or other special behavior is needed.
var NetDialTimeout = net.DialTimeout
// Dial dials and authenticates to an SMTP server. The returned SendCloser
// should be closed when done using it.
func (d *Dialer) Dial() (SendCloser, error) {
conn, err := NetDialTimeout("tcp", addr(d.Host, d.Port), d.Timeout)
if err != nil {
return nil, err
}
if d.SSL {
conn = tlsClient(conn, d.tlsConfig())
}
c, err := smtpNewClient(conn, d.Host)
if err != nil {
return nil, err
}
if d.Timeout > 0 {
conn.SetDeadline(time.Now().Add(d.Timeout))
}
if d.LocalName != "" {
if err := c.Hello(d.LocalName); err != nil {
return nil, err
}
}
if !d.SSL && d.StartTLSPolicy != NoStartTLS {
ok, _ := c.Extension("STARTTLS")
if !ok && d.StartTLSPolicy == MandatoryStartTLS {
err := StartTLSUnsupportedError{
Policy: d.StartTLSPolicy}
return nil, err
}
if ok {
if err := c.StartTLS(d.tlsConfig()); err != nil {
c.Close()
return nil, err
}
}
}
if d.Auth == nil && d.Username != "" {
if ok, auths := c.Extension("AUTH"); ok {
if strings.Contains(auths, "CRAM-MD5") {
d.Auth = smtp.CRAMMD5Auth(d.Username, d.Password)
} else if strings.Contains(auths, "LOGIN") &&
!strings.Contains(auths, "PLAIN") {
d.Auth = &loginAuth{
username: d.Username,
password: d.Password,
host: d.Host,
}
} else {
d.Auth = smtp.PlainAuth("", d.Username, d.Password, d.Host)
}
}
}
if d.Auth != nil {
if err = c.Auth(d.Auth); err != nil {
c.Close()
return nil, err
}
}
return &smtpSender{c, conn, d}, nil
}
func (d *Dialer) tlsConfig() *tls.Config {
if d.TLSConfig == nil {
return &tls.Config{ServerName: d.Host}
}
return d.TLSConfig
}
// StartTLSPolicy constants are valid values for Dialer.StartTLSPolicy.
type StartTLSPolicy int
const (
// OpportunisticStartTLS means that SMTP transactions are encrypted if
// STARTTLS is supported by the SMTP server. Otherwise, messages are
// sent in the clear. This is the default setting.
OpportunisticStartTLS StartTLSPolicy = iota
// MandatoryStartTLS means that SMTP transactions must be encrypted.
// SMTP transactions are aborted unless STARTTLS is supported by the
// SMTP server.
MandatoryStartTLS
// NoStartTLS means encryption is disabled and messages are sent in the
// clear.
NoStartTLS = -1
)
func (policy *StartTLSPolicy) String() string {
switch *policy {
case OpportunisticStartTLS:
return "OpportunisticStartTLS"
case MandatoryStartTLS:
return "MandatoryStartTLS"
case NoStartTLS:
return "NoStartTLS"
default:
return fmt.Sprintf("StartTLSPolicy:%v", *policy)
}
}
// StartTLSUnsupportedError is returned by Dial when connecting to an SMTP
// server that does not support STARTTLS.
type StartTLSUnsupportedError struct {
Policy StartTLSPolicy
}
func (e StartTLSUnsupportedError) Error() string {
return "gomail: " + e.Policy.String() + " required, but " +
"SMTP server does not support STARTTLS"
}
func addr(host string, port int) string {
return fmt.Sprintf("%s:%d", host, port)
}
// DialAndSend opens a connection to the SMTP server, sends the given emails and
// closes the connection.
func (d *Dialer) DialAndSend(m ...*Message) error {
s, err := d.Dial()
if err != nil {
return err
}
defer s.Close()
return Send(s, m...)
}
type smtpSender struct {
smtpClient
conn net.Conn
d *Dialer
}
func (c *smtpSender) retryError(err error) bool {
if !c.d.RetryFailure {
return false
}
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
return true
}
return err == io.EOF
}
func (c *smtpSender) Send(from string, to []string, msg io.WriterTo) error {
if c.d.Timeout > 0 {
c.conn.SetDeadline(time.Now().Add(c.d.Timeout))
}
if err := c.Mail(from); err != nil {
if c.retryError(err) {
// This is probably due to a timeout, so reconnect and try again.
sc, derr := c.d.Dial()
if derr == nil {
if s, ok := sc.(*smtpSender); ok {
*c = *s
return c.Send(from, to, msg)
}
}
}
return err
}
for _, addr := range to {
if err := c.Rcpt(addr); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err = msg.WriteTo(w); err != nil {
w.Close()
return err
}
return w.Close()
}
func (c *smtpSender) Close() error {
return c.Quit()
}
// Stubbed out for tests.
var (
tlsClient = tls.Client
smtpNewClient = func(conn net.Conn, host string) (smtpClient, error) {
return smtp.NewClient(conn, host)
}
)
type smtpClient interface {
Hello(string) error
Extension(string) (bool, string)
StartTLS(*tls.Config) error
Auth(smtp.Auth) error
Mail(string) error
Rcpt(string) error
Data() (io.WriteCloser, error)
Quit() error
Close() error
}

309
core/mail/writeto.go Normal file
View file

@ -0,0 +1,309 @@
package mail
import (
"encoding/base64"
"errors"
"io"
"mime"
"mime/multipart"
"path/filepath"
"strings"
"time"
)
// WriteTo implements io.WriterTo. It dumps the whole message into w.
func (m *Message) WriteTo(w io.Writer) (int64, error) {
mw := &messageWriter{w: w}
mw.writeMessage(m)
return mw.n, mw.err
}
func (w *messageWriter) writeMessage(m *Message) {
if _, ok := m.header["Mime-Version"]; !ok {
w.writeString("Mime-Version: 1.0\r\n")
}
if _, ok := m.header["Date"]; !ok {
w.writeHeader("Date", m.FormatDate(now()))
}
w.writeHeaders(m.header)
if m.hasMixedPart() {
w.openMultipart("mixed", m.boundary)
}
if m.hasRelatedPart() {
w.openMultipart("related", m.boundary)
}
if m.hasAlternativePart() {
w.openMultipart("alternative", m.boundary)
}
for _, part := range m.parts {
w.writePart(part, m.charset)
}
if m.hasAlternativePart() {
w.closeMultipart()
}
w.addFiles(m.embedded, false)
if m.hasRelatedPart() {
w.closeMultipart()
}
w.addFiles(m.attachments, true)
if m.hasMixedPart() {
w.closeMultipart()
}
}
func (m *Message) hasMixedPart() bool {
return (len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1
}
func (m *Message) hasRelatedPart() bool {
return (len(m.parts) > 0 && len(m.embedded) > 0) || len(m.embedded) > 1
}
func (m *Message) hasAlternativePart() bool {
return len(m.parts) > 1
}
type messageWriter struct {
w io.Writer
n int64
writers [3]*multipart.Writer
partWriter io.Writer
depth uint8
err error
}
func (w *messageWriter) openMultipart(mimeType, boundary string) {
mw := multipart.NewWriter(w)
if boundary != "" {
mw.SetBoundary(boundary)
}
contentType := "multipart/" + mimeType + ";\r\n boundary=" + mw.Boundary()
w.writers[w.depth] = mw
if w.depth == 0 {
w.writeHeader("Content-Type", contentType)
w.writeString("\r\n")
} else {
w.createPart(map[string][]string{
"Content-Type": {contentType},
})
}
w.depth++
}
func (w *messageWriter) createPart(h map[string][]string) {
w.partWriter, w.err = w.writers[w.depth-1].CreatePart(h)
}
func (w *messageWriter) closeMultipart() {
if w.depth > 0 {
w.writers[w.depth-1].Close()
w.depth--
}
}
func (w *messageWriter) writePart(p *part, charset string) {
w.writeHeaders(map[string][]string{
"Content-Type": {p.contentType + "; charset=" + charset},
"Content-Transfer-Encoding": {string(p.encoding)},
})
w.writeBody(p.copier, p.encoding)
}
func (w *messageWriter) addFiles(files []*file, isAttachment bool) {
for _, f := range files {
if _, ok := f.Header["Content-Type"]; !ok {
mediaType := mime.TypeByExtension(filepath.Ext(f.Name))
if mediaType == "" {
mediaType = "application/octet-stream"
}
f.setHeader("Content-Type", mediaType+`; name="`+f.Name+`"`)
}
if _, ok := f.Header["Content-Transfer-Encoding"]; !ok {
f.setHeader("Content-Transfer-Encoding", string(Base64))
}
if _, ok := f.Header["Content-Disposition"]; !ok {
var disp string
if isAttachment {
disp = "attachment"
} else {
disp = "inline"
}
f.setHeader("Content-Disposition", disp+`; filename="`+f.Name+`"`)
}
if !isAttachment {
if _, ok := f.Header["Content-ID"]; !ok {
f.setHeader("Content-ID", "<"+f.Name+">")
}
}
w.writeHeaders(f.Header)
w.writeBody(f.CopyFunc, Base64)
}
}
func (w *messageWriter) Write(p []byte) (int, error) {
if w.err != nil {
return 0, errors.New("gomail: cannot write as writer is in error")
}
var n int
n, w.err = w.w.Write(p)
w.n += int64(n)
return n, w.err
}
func (w *messageWriter) writeString(s string) {
n, _ := io.WriteString(w.w, s)
w.n += int64(n)
}
func (w *messageWriter) writeHeader(k string, v ...string) {
w.writeString(k)
if len(v) == 0 {
w.writeString(":\r\n")
return
}
w.writeString(": ")
// Max header line length is 78 characters in RFC 5322 and 76 characters
// in RFC 2047. So for the sake of simplicity we use the 76 characters
// limit.
charsLeft := 76 - len(k) - len(": ")
for i, s := range v {
// If the line is already too long, insert a newline right away.
if charsLeft < 1 {
if i == 0 {
w.writeString("\r\n ")
} else {
w.writeString(",\r\n ")
}
charsLeft = 75
} else if i != 0 {
w.writeString(", ")
charsLeft -= 2
}
// While the header content is too long, fold it by inserting a newline.
for len(s) > charsLeft {
s = w.writeLine(s, charsLeft)
charsLeft = 75
}
w.writeString(s)
if i := lastIndexByte(s, '\n'); i != -1 {
charsLeft = 75 - (len(s) - i - 1)
} else {
charsLeft -= len(s)
}
}
w.writeString("\r\n")
}
func (w *messageWriter) writeLine(s string, charsLeft int) string {
// If there is already a newline before the limit. Write the line.
if i := strings.IndexByte(s, '\n'); i != -1 && i < charsLeft {
w.writeString(s[:i+1])
return s[i+1:]
}
for i := charsLeft - 1; i >= 0; i-- {
if s[i] == ' ' {
w.writeString(s[:i])
w.writeString("\r\n ")
return s[i+1:]
}
}
// We could not insert a newline cleanly so look for a space or a newline
// even if it is after the limit.
for i := 75; i < len(s); i++ {
if s[i] == ' ' {
w.writeString(s[:i])
w.writeString("\r\n ")
return s[i+1:]
}
if s[i] == '\n' {
w.writeString(s[:i+1])
return s[i+1:]
}
}
// Too bad, no space or newline in the whole string. Just write everything.
w.writeString(s)
return ""
}
func (w *messageWriter) writeHeaders(h map[string][]string) {
if w.depth == 0 {
for k, v := range h {
if k != "Bcc" {
w.writeHeader(k, v...)
}
}
} else {
w.createPart(h)
}
}
func (w *messageWriter) writeBody(f func(io.Writer) error, enc Encoding) {
var subWriter io.Writer
if w.depth == 0 {
w.writeString("\r\n")
subWriter = w.w
} else {
subWriter = w.partWriter
}
if enc == Base64 {
wc := base64.NewEncoder(base64.StdEncoding, newBase64LineWriter(subWriter))
w.err = f(wc)
wc.Close()
} else if enc == Unencoded {
w.err = f(subWriter)
} else {
wc := newQPWriter(subWriter)
w.err = f(wc)
wc.Close()
}
}
// As required by RFC 2045, 6.7. (page 21) for quoted-printable, and
// RFC 2045, 6.8. (page 25) for base64.
const maxLineLen = 76
// base64LineWriter limits text encoded in base64 to 76 characters per line
type base64LineWriter struct {
w io.Writer
lineLen int
}
func newBase64LineWriter(w io.Writer) *base64LineWriter {
return &base64LineWriter{w: w}
}
func (w *base64LineWriter) Write(p []byte) (int, error) {
n := 0
for len(p)+w.lineLen > maxLineLen {
w.w.Write(p[:maxLineLen-w.lineLen])
w.w.Write([]byte("\r\n"))
p = p[maxLineLen-w.lineLen:]
n += maxLineLen - w.lineLen
w.lineLen = 0
}
w.w.Write(p)
w.lineLen += len(p)
return n + len(p), nil
}
// Stubbed out for testing.
var now = time.Now

View file

@ -35,6 +35,11 @@ func WriteNotFoundError(w http.ResponseWriter, method string, id string) {
w.Write([]byte("{Error: 'Not found'}"))
}
// WriteNotFound notifies HTTP client of 'record not found' error.
func WriteNotFound(w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
}
// WriteServerError notifies HTTP client of general application error.
func WriteServerError(w http.ResponseWriter, method string, err error) {
writeStatus(w, http.StatusBadRequest)

View file

@ -94,7 +94,7 @@ func (h *Handler) Sync(w http.ResponseWriter, r *http.Request) {
}
// User list from Documize
dmzUsers, err := h.Store.User.GetUsersForOrganization(ctx)
dmzUsers, err := h.Store.User.GetUsersForOrganization(ctx, "")
if err != nil {
result.Message = "Error: unable to fetch Documize users"
result.IsError = true

View file

@ -82,10 +82,10 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
perm := pm.Permission{}
perm.OrgID = ctx.OrgID
perm.Who = "user"
perm.Who = pm.UserPermission
perm.WhoID = ctx.UserID
perm.Scope = "object"
perm.Location = "category"
perm.Scope = pm.ScopeRow
perm.Location = pm.LocationCategory
perm.RefID = cat.RefID
perm.Action = pm.CategoryView
@ -155,15 +155,11 @@ func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) {
}
cat, err := h.Store.Category.GetAllBySpace(ctx, spaceID)
if err != nil && err != sql.ErrNoRows {
if err != nil {
response.WriteServerError(w, method, err)
return
}
if len(cat) == 0 {
cat = []category.Category{}
}
response.WriteJSON(w, cat)
}
@ -320,10 +316,6 @@ func (h *Handler) GetSummary(w http.ResponseWriter, r *http.Request) {
return
}
if len(s) == 0 {
s = []category.SummaryModel{}
}
response.WriteJSON(w, s)
}

View file

@ -80,8 +80,9 @@ func (s Scope) GetAllBySpace(ctx domain.RequestContext, spaceID string) (c []cat
))
ORDER BY category`, ctx.OrgID, spaceID, ctx.OrgID, ctx.OrgID, ctx.UserID, ctx.OrgID, ctx.UserID)
if err == sql.ErrNoRows {
if err == sql.ErrNoRows || len(c) == 0 {
err = nil
c = []category.Category{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select all categories for space %s", spaceID))
@ -190,20 +191,20 @@ func (s Scope) DeleteBySpace(ctx domain.RequestContext, spaceID string) (rows in
// GetSpaceCategorySummary returns number of documents and users for space categories.
func (s Scope) GetSpaceCategorySummary(ctx domain.RequestContext, spaceID string) (c []category.SummaryModel, err error) {
err = s.Runtime.Db.Select(&c, `
SELECT 'documents' as type, categoryid, COUNT(*) as count FROM categorymember WHERE orgid=? AND labelid=? GROUP BY categoryid, type
SELECT 'documents' as type, categoryid, COUNT(*) as count
FROM categorymember
WHERE orgid=? AND labelid=? GROUP BY categoryid, type
UNION ALL
SELECT 'users' as type, refid AS categoryid, count(*) AS count FROM permission WHERE orgid=? AND who='user' AND location='category'
AND refid IN (SELECT refid FROM category WHERE orgid=? AND labelid=?)
GROUP BY refid, type
UNION ALL
SELECT 'users' as type, p.refid AS categoryid, count(*) AS count FROM rolemember r LEFT JOIN permission p ON p.whoid=r.roleid
WHERE p.orgid=? AND p.who='role' AND p.location='category'
AND p.refid IN (SELECT refid FROM category WHERE orgid=? AND labelid=?)
GROUP BY p.refid, type`,
ctx.OrgID, spaceID, ctx.OrgID, ctx.OrgID, spaceID, ctx.OrgID, ctx.OrgID, spaceID)
SELECT 'users' as type, refid AS categoryid, count(*) AS count
FROM permission
WHERE orgid=? AND location='category'
AND refid IN (SELECT refid FROM category WHERE orgid=? AND labelid=?)
GROUP BY refid, type`,
ctx.OrgID, spaceID, ctx.OrgID, ctx.OrgID, spaceID /*, ctx.OrgID, ctx.OrgID, spaceID*/)
if err == sql.ErrNoRows {
if err == sql.ErrNoRows || len(c) == 0 {
err = nil
c = []category.SummaryModel{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("select category summary for space %s", spaceID))

349
domain/group/endpoint.go Normal file
View file

@ -0,0 +1,349 @@
// Copyright 2018 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
package group
import (
"encoding/json"
"io/ioutil"
"net/http"
"github.com/documize/community/core/env"
"github.com/documize/community/core/request"
"github.com/documize/community/core/response"
"github.com/documize/community/core/uniqueid"
"github.com/documize/community/domain"
"github.com/documize/community/model/audit"
"github.com/documize/community/model/group"
)
// Handler contains the runtime information such as logging and database.
type Handler struct {
Runtime *env.Runtime
Store *domain.Store
}
// Add saves new user group.
func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
method := "group.Add"
ctx := domain.GetRequestContext(r)
if !ctx.Administrator {
response.WriteForbiddenError(w)
return
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
response.WriteBadRequestError(w, method, "body")
h.Runtime.Log.Error(method, err)
return
}
var g group.Group
err = json.Unmarshal(body, &g)
if err != nil {
response.WriteBadRequestError(w, method, "group")
h.Runtime.Log.Error(method, err)
return
}
g.RefID = uniqueid.Generate()
g.OrgID = ctx.OrgID
if len(g.Name) == 0 {
response.WriteMissingDataError(w, method, "name")
return
}
ctx.Transaction, err = h.Runtime.Db.Beginx()
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
err = h.Store.Group.Add(ctx, g)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
ctx.Transaction.Commit()
g, err = h.Store.Group.Get(ctx, g.RefID)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
h.Store.Audit.Record(ctx, audit.EventTypeGroupAdd)
response.WriteJSON(w, g)
}
// Groups returns all user groups for org.
func (h *Handler) Groups(w http.ResponseWriter, r *http.Request) {
method := "group.Groups"
ctx := domain.GetRequestContext(r)
g, err := h.Store.Group.GetAll(ctx)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
response.WriteJSON(w, g)
}
// Update saves group name and description changes.
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
method := "group.Update"
ctx := domain.GetRequestContext(r)
if !ctx.Administrator {
response.WriteForbiddenError(w)
return
}
groupID := request.Param(r, "groupID")
if len(groupID) == 0 {
response.WriteMissingDataError(w, method, "groupID")
return
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
response.WriteBadRequestError(w, method, "body")
h.Runtime.Log.Error(method, err)
return
}
var g group.Group
err = json.Unmarshal(body, &g)
if err != nil {
response.WriteBadRequestError(w, method, "group")
h.Runtime.Log.Error(method, err)
return
}
g.OrgID = ctx.OrgID
g.RefID = groupID
ctx.Transaction, err = h.Runtime.Db.Beginx()
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
err = h.Store.Group.Update(ctx, g)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
ctx.Transaction.Commit()
h.Store.Audit.Record(ctx, audit.EventTypeGroupUpdate)
g, err = h.Store.Group.Get(ctx, g.RefID)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
response.WriteJSON(w, g)
}
// Delete removes group and associated member records.
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
method := "group.Delete"
ctx := domain.GetRequestContext(r)
if !ctx.Administrator {
response.WriteForbiddenError(w)
return
}
groupID := request.Param(r, "groupID")
if len(groupID) == 0 {
response.WriteMissingDataError(w, method, "groupID")
return
}
g, err := h.Store.Group.Get(ctx, groupID)
if err != nil {
response.WriteServerError(w, method, err)
return
}
ctx.Transaction, err = h.Runtime.Db.Beginx()
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
_, err = h.Store.Group.Delete(ctx, g.RefID)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
ctx.Transaction.Commit()
h.Store.Audit.Record(ctx, audit.EventTypeGroupDelete)
response.WriteEmpty(w)
}
// GetGroupMembers returns all users associated with given group.
func (h *Handler) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
method := "group.GetGroupMembers"
ctx := domain.GetRequestContext(r)
// Should be no reason for non-admin to see members
if !ctx.Administrator {
response.WriteForbiddenError(w)
return
}
groupID := request.Param(r, "groupID")
if len(groupID) == 0 {
response.WriteMissingDataError(w, method, "groupID")
return
}
m, err := h.Store.Group.GetGroupMembers(ctx, groupID)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
response.WriteJSON(w, m)
}
// JoinGroup adds user to group.
func (h *Handler) JoinGroup(w http.ResponseWriter, r *http.Request) {
method := "group.JoinGroup"
ctx := domain.GetRequestContext(r)
// Should be no reason for non-admin to see members
if !ctx.Administrator {
response.WriteForbiddenError(w)
return
}
groupID := request.Param(r, "groupID")
if len(groupID) == 0 {
response.WriteMissingDataError(w, method, "groupID")
return
}
userID := request.Param(r, "userID")
if len(userID) == 0 {
response.WriteMissingDataError(w, method, "userID")
return
}
var err error
ctx.Transaction, err = h.Runtime.Db.Beginx()
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
// safety first
err = h.Store.Group.LeaveGroup(ctx, groupID, userID)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
// now we can join group
err = h.Store.Group.JoinGroup(ctx, groupID, userID)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
ctx.Transaction.Commit()
h.Store.Audit.Record(ctx, audit.EventTypeGroupJoin)
response.WriteEmpty(w)
}
// LeaveGroup removes user to group.
func (h *Handler) LeaveGroup(w http.ResponseWriter, r *http.Request) {
method := "group.LeaveGroup"
ctx := domain.GetRequestContext(r)
// Should be no reason for non-admin to see members
if !ctx.Administrator {
response.WriteForbiddenError(w)
return
}
groupID := request.Param(r, "groupID")
if len(groupID) == 0 {
response.WriteMissingDataError(w, method, "groupID")
return
}
userID := request.Param(r, "userID")
if len(userID) == 0 {
response.WriteMissingDataError(w, method, "userID")
return
}
var err error
ctx.Transaction, err = h.Runtime.Db.Beginx()
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
err = h.Store.Group.LeaveGroup(ctx, groupID, userID)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
ctx.Transaction.Commit()
h.Store.Audit.Record(ctx, audit.EventTypeGroupLeave)
response.WriteEmpty(w)
}

165
domain/group/mysql/store.go Normal file
View file

@ -0,0 +1,165 @@
// Copyright 2018 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
package mysql
import (
"database/sql"
"fmt"
"time"
"github.com/documize/community/core/env"
"github.com/documize/community/domain"
"github.com/documize/community/domain/store/mysql"
"github.com/documize/community/model/group"
"github.com/pkg/errors"
)
// Scope provides data access to MySQL.
type Scope struct {
Runtime *env.Runtime
}
// Add inserts new user group into store.
func (s Scope) Add(ctx domain.RequestContext, g group.Group) (err error) {
g.Created = time.Now().UTC()
g.Revised = time.Now().UTC()
_, err = ctx.Transaction.Exec("INSERT INTO role (refid, orgid, role, purpose, created, revised) VALUES (?, ?, ?, ?, ?, ?)",
g.RefID, g.OrgID, g.Name, g.Purpose, g.Created, g.Revised)
if err != nil {
err = errors.Wrap(err, "insert group")
}
return
}
// Get returns requested group.
func (s Scope) Get(ctx domain.RequestContext, refID string) (g group.Group, err error) {
err = s.Runtime.Db.Get(&g,
`SELECT id, refid, orgid, role as name, purpose, created, revised FROM role WHERE orgid=? AND refid=?`,
ctx.OrgID, refID)
if err != nil {
err = errors.Wrap(err, "select group")
}
return
}
// GetAll returns all user groups for current orgID.
func (s Scope) GetAll(ctx domain.RequestContext) (groups []group.Group, err error) {
err = s.Runtime.Db.Select(&groups,
`SELECT a.id, a.refid, a.orgid, a.role as name, a.purpose, a.created, a.revised, COUNT(b.roleid) AS members
FROM role a
LEFT JOIN rolemember b ON a.refid=b.roleid
WHERE a.orgid=?
GROUP BY a.id, a.refid, a.orgid, a.role, a.purpose, a.created, a.revised
ORDER BY a.role`,
ctx.OrgID)
if err == sql.ErrNoRows || len(groups) == 0 {
err = nil
groups = []group.Group{}
}
if err != nil {
err = errors.Wrap(err, "select groups")
}
return
}
// Update group name and description.
func (s Scope) Update(ctx domain.RequestContext, g group.Group) (err error) {
g.Revised = time.Now().UTC()
_, err = ctx.Transaction.Exec("UPDATE role SET role=?, purpose=?, revised=? WHERE orgid=? AND refid=?",
g.Name, g.Purpose, g.Revised, ctx.OrgID, g.RefID)
if err != nil {
err = errors.Wrap(err, "update group")
}
return
}
// Delete removes group from store.
func (s Scope) Delete(ctx domain.RequestContext, refID string) (rows int64, err error) {
b := mysql.BaseQuery{}
b.DeleteConstrained(ctx.Transaction, "role", ctx.OrgID, refID)
return b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM rolemember WHERE orgid=\"%s\" AND roleid=\"%s\"", ctx.OrgID, refID))
}
// GetGroupMembers returns all user associated with given group.
func (s Scope) GetGroupMembers(ctx domain.RequestContext, groupID string) (members []group.Member, err error) {
err = s.Runtime.Db.Select(&members,
`SELECT a.id, a.orgid, a.roleid, a.userid,
IFNULL(b.firstname, '') as firstname, IFNULL(b.lastname, '') as lastname
FROM rolemember a
LEFT JOIN user b ON b.refid=a.userid
WHERE a.orgid=? AND a.roleid=?
ORDER BY b.firstname, b.lastname`,
ctx.OrgID, groupID)
if err == sql.ErrNoRows || len(members) == 0 {
err = nil
members = []group.Member{}
}
if err != nil {
err = errors.Wrap(err, "select members")
}
return
}
// JoinGroup adds user to group.
func (s Scope) JoinGroup(ctx domain.RequestContext, groupID, userID string) (err error) {
_, err = ctx.Transaction.Exec("INSERT INTO rolemember (orgid, roleid, userid) VALUES (?, ?, ?)", ctx.OrgID, groupID, userID)
if err != nil {
err = errors.Wrap(err, "insert group member")
}
return
}
// LeaveGroup removes user from group.
func (s Scope) LeaveGroup(ctx domain.RequestContext, groupID, userID string) (err error) {
b := mysql.BaseQuery{}
_, err = b.DeleteWhere(ctx.Transaction, fmt.Sprintf("DELETE FROM rolemember WHERE orgid=\"%s\" AND roleid=\"%s\" AND userid=\"%s\"", ctx.OrgID, groupID, userID))
if err != nil {
err = errors.Wrap(err, "clear group member")
}
return
}
// GetMembers returns members for every group.
// Useful when you need to bulk fetch membership records
// for subsequent processing.
func (s Scope) GetMembers(ctx domain.RequestContext) (r []group.Record, err error) {
err = s.Runtime.Db.Select(&r,
`SELECT a.id, a.orgid, a.roleid, a.userid, b.role as name, b.purpose
FROM rolemember a, role b
WHERE a.orgid=? AND a.roleid=b.refid
ORDER BY a.userid`,
ctx.OrgID)
if err == sql.ErrNoRows || len(r) == 0 {
err = nil
r = []group.Record{}
}
if err != nil {
err = errors.Wrap(err, "select group members")
}
return
}

View file

@ -76,7 +76,7 @@ background-color: #f6f6f6;
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<a href="{{.Url}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">View document</a>
<a href="{{.URL}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">View document</a>
</td>
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">

View file

@ -14,57 +14,50 @@
package mail
import (
"bytes"
"fmt"
"html/template"
"github.com/documize/community/server/web"
"github.com/documize/community/domain/smtp"
)
// DocumentApprover notifies user who has just been granted document approval rights.
func (m *Mailer) DocumentApprover(recipient, inviter, url, document string) {
method := "DocumentApprover"
m.LoadCredentials()
file, err := web.ReadFile("mail/document-approver.html")
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
emailTemplate := string(file)
m.Initialize()
// check inviter name
if inviter == "Hello You" || len(inviter) == 0 {
inviter = "Your colleague"
}
subject := fmt.Sprintf("%s has granted you document approval", inviter)
e := NewEmail()
e.From = m.Credentials.SMTPsender
e.To = []string{recipient}
e.Subject = subject
em := smtp.EmailMessage{}
em.Subject = fmt.Sprintf("%s has granted you document approval", inviter)
em.ToEmail = recipient
em.ToName = recipient
parameters := struct {
Subject string
Inviter string
Url string
URL string
Document string
}{
subject,
em.Subject,
inviter,
url,
document,
}
buffer := new(bytes.Buffer)
t := template.Must(template.New("emailTemplate").Parse(emailTemplate))
t.Execute(buffer, &parameters)
e.HTML = buffer.Bytes()
html, err := m.ParseTemplate("mail/document-approver.html", parameters)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
em.BodyHTML = html
err = e.Send(m.GetHost(), m.GetAuth())
ok, err := smtp.SendMessage(m.Dialer, m.Config, em)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to send email", method), err)
}
if !ok {
m.Runtime.Log.Info(fmt.Sprintf("%s unable to send email"))
}
}

View file

@ -72,7 +72,7 @@ background-color: #f6f6f6;
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<a href="{{.Url}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Click here to access Documize</a>
<a href="{{.URL}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Click here to access Documize</a>
</td>
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">

View file

@ -82,7 +82,7 @@ background-color: #f6f6f6;
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<a href="{{.Url}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Click here to access Documize</a>
<a href="{{.URL}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Click here to access Documize</a>
</td>
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">

View file

@ -9,61 +9,57 @@
//
// https://documize.com
// jshint ignore:start
package mail
import (
"net/smtp"
"strings"
"bytes"
"html/template"
"github.com/documize/community/core/env"
"github.com/documize/community/core/mail"
"github.com/documize/community/domain"
"github.com/documize/community/domain/setting"
ds "github.com/documize/community/domain/smtp"
"github.com/documize/community/server/web"
)
// Mailer provides emailing facilities
type Mailer struct {
Runtime *env.Runtime
Store *domain.Store
Context domain.RequestContext
Credentials Credentials
Runtime *env.Runtime
Store *domain.Store
Context domain.RequestContext
Config ds.Config
Dialer *mail.Dialer
}
// Credentials holds SMTP endpoint and authentication methods
type Credentials struct {
SMTPuserid string
SMTPpassword string
SMTPhost string
SMTPport string
SMTPsender string
// Initialize prepares mailer instance for action.
func (m *Mailer) Initialize() {
m.Config = setting.GetSMTPConfig(m.Store)
m.Dialer, _ = ds.Connect(m.Config)
}
// GetAuth to return SMTP authentication details
func (m *Mailer) GetAuth() smtp.Auth {
a := smtp.PlainAuth("", m.Credentials.SMTPuserid, m.Credentials.SMTPpassword, m.Credentials.SMTPhost)
return a
// Send prepares and sends email.
func (m *Mailer) Send(em ds.EmailMessage) (ok bool, err error) {
ok, err = ds.SendMessage(m.Dialer, m.Config, em)
return
}
// GetHost to return SMTP host details
func (m *Mailer) GetHost() string {
h := m.Credentials.SMTPhost + ":" + m.Credentials.SMTPport
return h
}
// LoadCredentials loads up SMTP details from database
func (m *Mailer) LoadCredentials() {
userID, _ := m.Store.Setting.Get("SMTP", "userid")
m.Credentials.SMTPuserid = strings.TrimSpace(userID)
pwd, _ := m.Store.Setting.Get("SMTP", "password")
m.Credentials.SMTPpassword = strings.TrimSpace(pwd)
host, _ := m.Store.Setting.Get("SMTP", "host")
m.Credentials.SMTPhost = strings.TrimSpace(host)
port, _ := m.Store.Setting.Get("SMTP", "port")
m.Credentials.SMTPport = strings.TrimSpace(port)
sender, _ := m.Store.Setting.Get("SMTP", "sender")
m.Credentials.SMTPsender = strings.TrimSpace(sender)
// ParseTemplate produces email template.
func (m *Mailer) ParseTemplate(filename string, params interface{}) (html string, err error) {
html = ""
file, err := web.ReadFile(filename)
if err != nil {
return
}
emailTemplate := string(file)
buffer := new(bytes.Buffer)
t := template.Must(template.New("emailTemplate").Parse(emailTemplate))
t.Execute(buffer, &params)
html = buffer.String()
return
}

View file

@ -79,7 +79,7 @@ background-color: #f6f6f6;
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<a href="{{.Url}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Click here to reset your password</a>
<a href="{{.URL}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Click here to reset your password</a>
</td>
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">

View file

@ -75,7 +75,7 @@ background-color: #f6f6f6;
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<a href="{{.Url}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Login to Documize</a>
<a href="{{.URL}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Login to Documize</a>
</td>
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">

View file

@ -81,7 +81,7 @@ background-color: #f6f6f6;
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
<a href="{{.Url}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Go to Documize</a>
<a href="{{.URL}}" class="btn-primary" style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; color: #FFF; text-decoration: none; line-height: 2; font-weight: bold; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background: #4ccb6a; margin: 0; padding: 0; border-color: #4ccb6a; border-style: solid; border-width: 10px 20px;">Go to Documize</a>
</td>
</tr>
<tr style="font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 14px; margin: 0; padding: 0;">

View file

@ -1,374 +0,0 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
/*
Elements of the software in this file were modified from github.com/jordan-wright/email and
are subject to the licence below:
The MIT License (MIT)
Copyright (c) 2013 Jordan Wright
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
// Package mail sends transactional emails.
// The code in smtp.go is designed to provide an "email interface for humans".
// Designed to be robust and flexible, the email package aims to make sending email easy without getting in the way.
package mail
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/mail"
"net/smtp"
"net/textproto"
"os"
"path/filepath"
"strings"
"time"
)
const (
// MaxLineLength is the maximum line length per RFC 2045
MaxLineLength = 76
)
// Email is the type used for email messages
type Email struct {
From string
To []string
Bcc []string
Cc []string
Subject string
Text []byte // Plaintext message (optional)
HTML []byte // Html message (optional)
Headers textproto.MIMEHeader
Attachments []*Attachment
ReadReceipt []string
}
// NewEmail creates an Email, and returns the pointer to it.
func NewEmail() *Email {
return &Email{Headers: textproto.MIMEHeader{}}
}
// Attach is used to attach content from an io.Reader to the email.
// Required parameters include an io.Reader, the desired filename for the attachment, and the Content-Type
// The function will return the created Attachment for reference, as well as nil for the error, if successful.
func (e *Email) Attach(r io.Reader, filename string, c string) (a *Attachment, err error) {
var buffer bytes.Buffer
if _, err = io.Copy(&buffer, r); err != nil {
return
}
at := &Attachment{
Filename: filename,
Header: textproto.MIMEHeader{},
Content: buffer.Bytes(),
}
// Get the Content-Type to be used in the MIMEHeader
if c != "" {
at.Header.Set("Content-Type", c)
} else {
// If the Content-Type is blank, set the Content-Type to "application/octet-stream"
at.Header.Set("Content-Type", "application/octet-stream")
}
at.Header.Set("Content-Disposition", fmt.Sprintf("attachment;\r\n filename=\"%s\"", filename))
at.Header.Set("Content-ID", fmt.Sprintf("<%s>", filename))
at.Header.Set("Content-Transfer-Encoding", "base64")
e.Attachments = append(e.Attachments, at)
return at, nil
}
// AttachFile is used to attach content to the email.
// It attempts to open the file referenced by filename and, if successful, creates an Attachment.
// This Attachment is then appended to the slice of Email.Attachments.
// The function will then return the Attachment for reference, as well as nil for the error, if successful.
func (e *Email) AttachFile(filename string) (a *Attachment, err error) {
f, err := os.Open(filename)
if err != nil {
return
}
ct := mime.TypeByExtension(filepath.Ext(filename))
basename := filepath.Base(filename)
return e.Attach(f, basename, ct)
}
// msgHeaders merges the Email's various fields and custom headers together in a
// standards compliant way to create a MIMEHeader to be used in the resulting
// message. It does not alter e.Headers.
//
// "e"'s fields To, Cc, From, Subject will be used unless they are present in
// e.Headers. Unless set in e.Headers, "Date" will filled with the current time.
func (e *Email) msgHeaders() textproto.MIMEHeader {
res := make(textproto.MIMEHeader, len(e.Headers)+4)
if e.Headers != nil {
for _, h := range []string{"To", "Cc", "From", "Subject", "Date"} {
if v, ok := e.Headers[h]; ok {
res[h] = v
}
}
}
// Set headers if there are values.
if _, ok := res["To"]; !ok && len(e.To) > 0 {
res.Set("To", strings.Join(e.To, ", "))
}
if _, ok := res["Cc"]; !ok && len(e.Cc) > 0 {
res.Set("Cc", strings.Join(e.Cc, ", "))
}
if _, ok := res["Subject"]; !ok && e.Subject != "" {
res.Set("Subject", e.Subject)
}
// Date and From are required headers.
if _, ok := res["From"]; !ok {
res.Set("From", e.From)
}
if _, ok := res["Date"]; !ok {
res.Set("Date", time.Now().Format(time.RFC1123Z))
}
if _, ok := res["Mime-Version"]; !ok {
res.Set("Mime-Version", "1.0")
}
for field, vals := range e.Headers {
if _, ok := res[field]; !ok {
res[field] = vals
}
}
return res
}
// Bytes converts the Email object to a []byte representation, including all needed MIMEHeaders, boundaries, etc.
func (e *Email) Bytes() ([]byte, error) {
// TODO: better guess buffer size
buff := bytes.NewBuffer(make([]byte, 0, 4096))
headers := e.msgHeaders()
w := multipart.NewWriter(buff)
// TODO: determine the content type based on message/attachment mix.
headers.Set("Content-Type", "multipart/mixed;\r\n boundary="+w.Boundary())
headerToBytes(buff, headers)
io.WriteString(buff, "\r\n")
// Start the multipart/mixed part
fmt.Fprintf(buff, "--%s\r\n", w.Boundary())
header := textproto.MIMEHeader{}
// Check to see if there is a Text or HTML field
if len(e.Text) > 0 || len(e.HTML) > 0 {
subWriter := multipart.NewWriter(buff)
// Create the multipart alternative part
header.Set("Content-Type", fmt.Sprintf("multipart/alternative;\r\n boundary=%s\r\n", subWriter.Boundary()))
// Write the header
headerToBytes(buff, header)
// Create the body sections
if len(e.Text) > 0 {
header.Set("Content-Type", fmt.Sprintf("text/plain; charset=UTF-8"))
header.Set("Content-Transfer-Encoding", "quoted-printable")
if _, err := subWriter.CreatePart(header); err != nil {
return nil, err
}
// Write the text
if err := quotePrintEncode(buff, e.Text); err != nil {
return nil, err
}
}
if len(e.HTML) > 0 {
header.Set("Content-Type", fmt.Sprintf("text/html; charset=UTF-8"))
header.Set("Content-Transfer-Encoding", "quoted-printable")
if _, err := subWriter.CreatePart(header); err != nil {
return nil, err
}
// Write the text
if err := quotePrintEncode(buff, e.HTML); err != nil {
return nil, err
}
}
if err := subWriter.Close(); err != nil {
return nil, err
}
}
// Create attachment part, if necessary
for _, a := range e.Attachments {
ap, err := w.CreatePart(a.Header)
if err != nil {
return nil, err
}
// Write the base64Wrapped content to the part
base64Wrap(ap, a.Content)
}
if err := w.Close(); err != nil {
return nil, err
}
return buff.Bytes(), nil
}
// Send an email using the given host and SMTP auth (optional), returns any error thrown by smtp.SendMail
// This function merges the To, Cc, and Bcc fields and calls the smtp.SendMail function using the Email.Bytes() output as the message
func (e *Email) Send(addr string, a smtp.Auth) error {
// Merge the To, Cc, and Bcc fields
to := make([]string, 0, len(e.To)+len(e.Cc)+len(e.Bcc))
to = append(append(append(to, e.To...), e.Cc...), e.Bcc...)
for i := 0; i < len(to); i++ {
addr, err := mail.ParseAddress(to[i])
if err != nil {
return err
}
to[i] = addr.Address
}
// Check to make sure there is at least one recipient and one "From" address
if e.From == "" || len(to) == 0 {
return errors.New("Must specify at least one From address and one To address")
}
from, err := mail.ParseAddress(e.From)
if err != nil {
return err
}
raw, err := e.Bytes()
if err != nil {
return err
}
return smtpSendMail(addr, a, from.Address, to, raw)
}
var smtpSendMail = smtp.SendMail // so that it can be overloaded for testing
// Attachment is a struct representing an email attachment.
// Based on the mime/multipart.FileHeader struct, Attachment contains the name, MIMEHeader, and content of the attachment in question
type Attachment struct {
Filename string
Header textproto.MIMEHeader
Content []byte
}
// quotePrintEncode writes the quoted-printable text to the IO Writer (according to RFC 2045)
func quotePrintEncode(w io.Writer, body []byte) error {
var buf [3]byte
mc := 0
for _, c := range body {
// We're assuming Unix style text formats as input (LF line break), and
// quoted-printable uses CRLF line breaks. (Literal CRs will become
// "=0D", but probably shouldn't be there to begin with!)
if c == '\n' {
_, err := io.WriteString(w, "\r\n")
if err != nil {
return err
}
mc = 0
continue
}
var nextOut []byte
if isPrintable[c] {
buf[0] = c
nextOut = buf[:1]
} else {
nextOut = buf[:]
qpEscape(nextOut, c)
}
// Add a soft line break if the next (encoded) byte would push this line
// to or past the limit.
if mc+len(nextOut) >= MaxLineLength {
if _, err := io.WriteString(w, "=\r\n"); err != nil {
return err
}
mc = 0
}
if _, err := w.Write(nextOut); err != nil {
return err
}
mc += len(nextOut)
}
// No trailing end-of-line?? Soft line break, then. TODO: is this sane?
if mc > 0 {
_, err := io.WriteString(w, "=\r\n")
if err != nil {
return err
}
}
return nil
}
// isPrintable holds true if the byte given is "printable" according to RFC 2045, false otherwise
var isPrintable [256]bool
func init() {
for c := '!'; c <= '<'; c++ {
isPrintable[c] = true
}
for c := '>'; c <= '~'; c++ {
isPrintable[c] = true
}
isPrintable[' '] = true
isPrintable['\n'] = true
isPrintable['\t'] = true
}
// qpEscape is a helper function for quotePrintEncode which escapes a
// non-printable byte. Expects len(dest) == 3.
func qpEscape(dest []byte, c byte) {
const nums = "0123456789ABCDEF"
dest[0] = '='
dest[1] = nums[(c&0xf0)>>4]
dest[2] = nums[(c & 0xf)]
}
// base64Wrap encodes the attachment content, and wraps it according to RFC 2045 standards (every 76 chars)
// The output is then written to the specified io.Writer
func base64Wrap(w io.Writer, b []byte) {
// 57 raw bytes per 76-byte base64 line.
const maxRaw = 57
// Buffer for each line, including trailing CRLF.
buffer := make([]byte, MaxLineLength+len("\r\n"))
copy(buffer[MaxLineLength:], "\r\n")
// Process raw chunks until there's no longer enough to fill a line.
for len(b) >= maxRaw {
base64.StdEncoding.Encode(buffer, b[:maxRaw])
w.Write(buffer)
b = b[maxRaw:]
}
// Handle the last chunk of bytes.
if len(b) > 0 {
out := buffer[:base64.StdEncoding.EncodedLen(len(b))]
base64.StdEncoding.Encode(out, b)
out = append(out, "\r\n"...)
w.Write(out)
}
}
// headerToBytes renders "header" to "buff". If there are multiple values for a
// field, multiple "Field: value\r\n" lines will be emitted.
func headerToBytes(buff *bytes.Buffer, header textproto.MIMEHeader) {
for field, vals := range header {
for _, subval := range vals {
io.WriteString(buff, field+": "+subval+"\r\n")
}
}
}

View file

@ -9,114 +9,100 @@
//
// https://documize.com
// jshint ignore:start
package mail
import (
"bytes"
"fmt"
"html/template"
"github.com/documize/community/server/web"
"github.com/documize/community/domain/smtp"
)
// ShareSpaceExistingUser provides an existing user with a link to a newly shared space.
func (m *Mailer) ShareSpaceExistingUser(recipient, inviter, url, folder, intro string) {
method := "ShareSpaceExistingUser"
m.LoadCredentials()
file, err := web.ReadFile("mail/share-space-existing-user.html")
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
emailTemplate := string(file)
m.Initialize()
// check inviter name
if inviter == "Hello You" || len(inviter) == 0 {
inviter = "Your colleague"
}
subject := fmt.Sprintf("%s has shared %s with you", inviter, folder)
e := NewEmail()
e.From = m.Credentials.SMTPsender
e.To = []string{recipient}
e.Subject = subject
em := smtp.EmailMessage{}
em.Subject = fmt.Sprintf("%s has shared %s with you", inviter, folder)
em.ToEmail = recipient
em.ToName = recipient
parameters := struct {
Subject string
Inviter string
Url string
URL string
Folder string
Intro string
}{
subject,
em.Subject,
inviter,
url,
folder,
intro,
}
buffer := new(bytes.Buffer)
t := template.Must(template.New("emailTemplate").Parse(emailTemplate))
t.Execute(buffer, &parameters)
e.HTML = buffer.Bytes()
html, err := m.ParseTemplate("mail/share-space-existing-user.html", parameters)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
em.BodyHTML = html
err = e.Send(m.GetHost(), m.GetAuth())
ok, err := smtp.SendMessage(m.Dialer, m.Config, em)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to send email", method), err)
}
if !ok {
m.Runtime.Log.Info(fmt.Sprintf("%s unable to send email"))
}
}
// ShareSpaceNewUser invites new user providing Credentials, explaining the product and stating who is inviting them.
func (m *Mailer) ShareSpaceNewUser(recipient, inviter, url, space, invitationMessage string) {
method := "ShareSpaceNewUser"
m.LoadCredentials()
file, err := web.ReadFile("mail/share-space-new-user.html")
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
emailTemplate := string(file)
m.Initialize()
// check inviter name
if inviter == "Hello You" || len(inviter) == 0 {
inviter = "Your colleague"
}
subject := fmt.Sprintf("%s has shared %s with you on Documize", inviter, space)
e := NewEmail()
e.From = m.Credentials.SMTPsender
e.To = []string{recipient}
e.Subject = subject
em := smtp.EmailMessage{}
em.Subject = fmt.Sprintf("%s has shared %s with you on Documize", inviter, space)
em.ToEmail = recipient
em.ToName = recipient
parameters := struct {
Subject string
Inviter string
Url string
URL string
Invitation string
Folder string
}{
subject,
em.Subject,
inviter,
url,
invitationMessage,
space,
}
buffer := new(bytes.Buffer)
t := template.Must(template.New("emailTemplate").Parse(emailTemplate))
t.Execute(buffer, &parameters)
e.HTML = buffer.Bytes()
html, err := m.ParseTemplate("mail/share-space-new-user.html", parameters)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
em.BodyHTML = html
err = e.Send(m.GetHost(), m.GetAuth())
ok, err := smtp.SendMessage(m.Dialer, m.Config, em)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to send email", method), err)
}
if !ok {
m.Runtime.Log.Info(fmt.Sprintf("%s unable to send email"))
}
}

View file

@ -9,149 +9,130 @@
//
// https://documize.com
// jshint ignore:start
package mail
import (
"bytes"
"fmt"
"html/template"
"github.com/documize/community/server/web"
"github.com/documize/community/domain/smtp"
)
// InviteNewUser invites someone new providing credentials, explaining the product and stating who is inviting them.
func (m *Mailer) InviteNewUser(recipient, inviter, url, username, password string) {
method := "InviteNewUser"
m.LoadCredentials()
file, err := web.ReadFile("mail/invite-new-user.html")
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
emailTemplate := string(file)
m.Initialize()
// check inviter name
if inviter == "Hello You" || len(inviter) == 0 {
inviter = "Your colleague"
}
subject := fmt.Sprintf("%s has invited you to Documize", inviter)
e := NewEmail()
e.From = m.Credentials.SMTPsender
e.To = []string{recipient}
e.Subject = subject
em := smtp.EmailMessage{}
em.Subject = fmt.Sprintf("%s has invited you to Documize", inviter)
em.ToEmail = recipient
em.ToName = recipient
parameters := struct {
Subject string
Inviter string
Url string
URL string
Username string
Password string
}{
subject,
em.Subject,
inviter,
url,
recipient,
password,
}
buffer := new(bytes.Buffer)
t := template.Must(template.New("emailTemplate").Parse(emailTemplate))
t.Execute(buffer, &parameters)
e.HTML = buffer.Bytes()
html, err := m.ParseTemplate("mail/invite-new-user.html", parameters)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
em.BodyHTML = html
err = e.Send(m.GetHost(), m.GetAuth())
ok, err := smtp.SendMessage(m.Dialer, m.Config, em)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to send email", method), err)
}
if !ok {
m.Runtime.Log.Info(fmt.Sprintf("%s unable to send email"))
}
}
// InviteExistingUser invites a known user to an organization.
func (m *Mailer) InviteExistingUser(recipient, inviter, url string) {
method := "InviteExistingUser"
m.LoadCredentials()
file, err := web.ReadFile("mail/invite-existing-user.html")
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
emailTemplate := string(file)
m.Initialize()
// check inviter name
if inviter == "Hello You" || len(inviter) == 0 {
inviter = "Your colleague"
}
subject := fmt.Sprintf("%s has invited you to their Documize account", inviter)
e := NewEmail()
e.From = m.Credentials.SMTPsender
e.To = []string{recipient}
e.Subject = subject
em := smtp.EmailMessage{}
em.Subject = fmt.Sprintf("%s has invited you to their Documize account", inviter)
em.ToEmail = recipient
em.ToName = recipient
parameters := struct {
Subject string
Inviter string
Url string
URL string
}{
subject,
em.Subject,
inviter,
url,
}
buffer := new(bytes.Buffer)
t := template.Must(template.New("emailTemplate").Parse(emailTemplate))
t.Execute(buffer, &parameters)
e.HTML = buffer.Bytes()
html, err := m.ParseTemplate("mail/invite-existing-user.html", parameters)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
em.BodyHTML = html
err = e.Send(m.GetHost(), m.GetAuth())
ok, err := smtp.SendMessage(m.Dialer, m.Config, em)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to send email", method), err)
}
if !ok {
m.Runtime.Log.Info(fmt.Sprintf("%s unable to send email"))
}
}
// PasswordReset sends a reset email with an embedded token.
func (m *Mailer) PasswordReset(recipient, url string) {
method := "PasswordReset"
m.LoadCredentials()
m.Initialize()
file, err := web.ReadFile("mail/password-reset.html")
em := smtp.EmailMessage{}
em.Subject = "Documize password reset request"
em.ToEmail = recipient
em.ToName = recipient
parameters := struct {
Subject string
URL string
}{
em.Subject,
url,
}
html, err := m.ParseTemplate("mail/password-reset.html", parameters)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to load email template", method), err)
return
}
em.BodyHTML = html
emailTemplate := string(file)
subject := "Documize password reset request"
e := NewEmail()
e.From = m.Credentials.SMTPsender //e.g. "Documize <hello@documize.com>"
e.To = []string{recipient}
e.Subject = subject
parameters := struct {
Subject string
Url string
}{
subject,
url,
}
buffer := new(bytes.Buffer)
t := template.Must(template.New("emailTemplate").Parse(emailTemplate))
t.Execute(buffer, &parameters)
e.HTML = buffer.Bytes()
err = e.Send(m.GetHost(), m.GetAuth())
ok, err := smtp.SendMessage(m.Dialer, m.Config, em)
if err != nil {
m.Runtime.Log.Error(fmt.Sprintf("%s - unable to send email", method), err)
}
if !ok {
m.Runtime.Log.Info(fmt.Sprintf("%s unable to send email"))
}
}

View file

@ -41,8 +41,8 @@ func (h *Handler) Meta(w http.ResponseWriter, r *http.Request) {
org, err := h.Store.Organization.GetOrganizationByDomain(data.URL)
if err != nil {
h.Runtime.Log.Error("unable to fetch request meta for "+data.URL, err)
response.WriteForbiddenError(w)
h.Runtime.Log.Info("unable to fetch request meta for " + data.URL)
response.WriteNotFound(w)
return
}

View file

@ -79,16 +79,15 @@ func (s Scope) GetOrganizationByDomain(subdomain string) (o org.Organization, er
return
}
err = s.Runtime.Db.Get(&o, "SELECT id, refid, company, title, message, url, domain, service as conversionendpoint, email, serial, active, allowanonymousaccess, authprovider, coalesce(authconfig,JSON_UNQUOTE('{}')) as authconfig, created, revised FROM organization WHERE domain=? AND active=1",
subdomain)
// match on given domain name
err = s.Runtime.Db.Get(&o, "SELECT id, refid, company, title, message, url, domain, service as conversionendpoint, email, serial, active, allowanonymousaccess, authprovider, coalesce(authconfig,JSON_UNQUOTE('{}')) as authconfig, created, revised FROM organization WHERE domain=? AND active=1", subdomain)
if err == nil {
return
}
err = nil
// we try to match on empty domain as last resort
// match on empty domain as last resort
err = s.Runtime.Db.Get(&o, "SELECT id, refid, company, title, message, url, domain, service as conversionendpoint, email, serial, active, allowanonymousaccess, authprovider, coalesce(authconfig,JSON_UNQUOTE('{}')) as authconfig, created, revised FROM organization WHERE domain='' AND active=1")
if err != nil && err != sql.ErrNoRows {
err = errors.Wrap(err, "unable to execute select for empty subdomain")
}

View file

@ -28,6 +28,7 @@ import (
"github.com/documize/community/domain"
"github.com/documize/community/domain/mail"
"github.com/documize/community/model/audit"
"github.com/documize/community/model/group"
"github.com/documize/community/model/permission"
"github.com/documize/community/model/space"
"github.com/documize/community/model/user"
@ -123,52 +124,85 @@ func (h *Handler) SetSpacePermissions(w http.ResponseWriter, r *http.Request) {
hasEveryoneRole := false
roleCount := 0
// Permissions can be assigned to both groups and individual users.
// Pre-fetch users with group membership to help us work out
// if user belongs to a group with permissions.
groupMembers, err := h.Store.Group.GetMembers(ctx)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
for _, perm := range model.Permissions {
perm.OrgID = ctx.OrgID
perm.SpaceID = id
isGroup := perm.Who == permission.GroupPermission
groupRecords := []group.Record{}
if isGroup {
// get group records for just this group
groupRecords = group.FilterGroupRecords(groupMembers, perm.WhoID)
}
// Ensure the space owner always has access!
if perm.UserID == ctx.UserID {
if (!isGroup && perm.WhoID == ctx.UserID) ||
(isGroup && group.UserHasGroupMembership(groupMembers, perm.WhoID, ctx.UserID)) {
me = true
}
// Only persist if there is a role!
if permission.HasAnyPermission(perm) {
// identify publically shared spaces
if perm.UserID == "" {
perm.UserID = "0"
if perm.WhoID == "" {
perm.WhoID = user.EveryoneUserID
}
if perm.UserID == "0" {
if perm.WhoID == user.EveryoneUserID {
hasEveryoneRole = true
}
// Encode group/user permission and save to store.
r := permission.EncodeUserPermissions(perm)
roleCount++
for _, p := range r {
err = h.Store.Permission.AddPermission(ctx, p)
if err != nil {
h.Runtime.Log.Error("set permission", err)
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
}
}
// We send out space invitation emails to those users
// that have *just* been given permissions.
if _, isExisting := previousRoleUsers[perm.UserID]; !isExisting {
if _, isExisting := previousRoleUsers[perm.WhoID]; !isExisting {
// we skip 'everyone'
if perm.WhoID != user.EveryoneUserID {
whoToEmail := []string{}
// we skip 'everyone' (user id != empty string)
if perm.UserID != "0" && perm.UserID != "" {
existingUser, err := h.Store.User.Get(ctx, perm.UserID)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
break
if isGroup {
// send email to each group member
for i := range groupRecords {
whoToEmail = append(whoToEmail, groupRecords[i].UserID)
}
} else {
// send email to individual user
whoToEmail = append(whoToEmail, perm.WhoID)
}
mailer := mail.Mailer{Runtime: h.Runtime, Store: h.Store, Context: ctx}
go mailer.ShareSpaceExistingUser(existingUser.Email, inviter.Fullname(), url, sp.Name, model.Message)
h.Runtime.Log.Info(fmt.Sprintf("%s is sharing space %s with existing user %s", inviter.Email, sp.Name, existingUser.Email))
for i := range whoToEmail {
existingUser, err := h.Store.User.Get(ctx, whoToEmail[i])
if err != nil {
h.Runtime.Log.Error(method, err)
continue
}
mailer := mail.Mailer{Runtime: h.Runtime, Store: h.Store, Context: ctx}
go mailer.ShareSpaceExistingUser(existingUser.Email, inviter.Fullname(), url, sp.Name, model.Message)
h.Runtime.Log.Info(fmt.Sprintf("%s is sharing space %s with existing user %s", inviter.Email, sp.Name, existingUser.Email))
}
}
}
}
@ -178,10 +212,10 @@ func (h *Handler) SetSpacePermissions(w http.ResponseWriter, r *http.Request) {
if !me {
perm := permission.Permission{}
perm.OrgID = ctx.OrgID
perm.Who = "user"
perm.Who = permission.UserPermission
perm.WhoID = ctx.UserID
perm.Scope = "object"
perm.Location = "space"
perm.Scope = permission.ScopeRow
perm.Location = permission.LocationSpace
perm.RefID = id
perm.Action = "" // we send array for actions below
@ -232,13 +266,11 @@ func (h *Handler) GetSpacePermissions(w http.ResponseWriter, r *http.Request) {
}
perms, err := h.Store.Permission.GetSpacePermissions(ctx, spaceID)
if err != nil && err != sql.ErrNoRows {
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
if len(perms) == 0 {
perms = []permission.Permission{}
}
userPerms := make(map[string][]permission.Permission)
for _, p := range perms {
@ -250,6 +282,40 @@ func (h *Handler) GetSpacePermissions(w http.ResponseWriter, r *http.Request) {
records = append(records, permission.DecodeUserPermissions(up))
}
// populate user/group name for thing that has permission record
groups, err := h.Store.Group.GetAll(ctx)
if err != nil && err != sql.ErrNoRows {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
for i := range records {
if records[i].Who == permission.GroupPermission {
for j := range groups {
if records[i].WhoID == groups[j].RefID {
records[i].Name = groups[j].Name
break
}
}
}
if records[i].Who == permission.UserPermission {
if records[i].WhoID == user.EveryoneUserID {
records[i].Name = user.EveryoneUserName
} else {
u, err := h.Store.User.Get(ctx, records[i].WhoID)
if err != nil {
h.Runtime.Log.Info(fmt.Sprintf("user not found %s", records[i].WhoID))
h.Runtime.Log.Error(method, err)
continue
}
records[i].Name = u.Fullname()
}
}
}
response.WriteJSON(w, records)
}
@ -265,13 +331,10 @@ func (h *Handler) GetUserSpacePermissions(w http.ResponseWriter, r *http.Request
}
perms, err := h.Store.Permission.GetUserSpacePermissions(ctx, spaceID)
if err != nil && err != sql.ErrNoRows {
if err != nil {
response.WriteServerError(w, method, err)
return
}
if len(perms) == 0 {
perms = []permission.Permission{}
}
record := permission.DecodeUserPermissions(perms)
response.WriteJSON(w, record)
@ -293,9 +356,6 @@ func (h *Handler) GetCategoryViewers(w http.ResponseWriter, r *http.Request) {
response.WriteServerError(w, method, err)
return
}
if len(u) == 0 {
u = []user.User{}
}
response.WriteJSON(w, u)
}
@ -311,16 +371,58 @@ func (h *Handler) GetCategoryPermissions(w http.ResponseWriter, r *http.Request)
return
}
u, err := h.Store.Permission.GetCategoryPermissions(ctx, categoryID)
if err != nil && err != sql.ErrNoRows {
perms, err := h.Store.Permission.GetCategoryPermissions(ctx, categoryID)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
if len(u) == 0 {
u = []permission.Permission{}
userPerms := make(map[string][]permission.Permission)
for _, p := range perms {
userPerms[p.WhoID] = append(userPerms[p.WhoID], p)
}
response.WriteJSON(w, u)
records := []permission.CategoryRecord{}
for _, up := range userPerms {
records = append(records, permission.DecodeUserCategoryPermissions(up))
}
// populate user/group name for thing that has permission record
groups, err := h.Store.Group.GetAll(ctx)
if err != nil && err != sql.ErrNoRows {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
for i := range records {
if records[i].Who == permission.GroupPermission {
for j := range groups {
if records[i].WhoID == groups[j].RefID {
records[i].Name = groups[j].Name
break
}
}
}
if records[i].Who == permission.UserPermission {
if records[i].WhoID == user.EveryoneUserID {
records[i].Name = user.EveryoneUserName
} else {
u, err := h.Store.User.Get(ctx, records[i].WhoID)
if err != nil {
h.Runtime.Log.Info(fmt.Sprintf("user not found %s", records[i].WhoID))
h.Runtime.Log.Error(method, err)
continue
}
records[i].Name = u.Fullname()
}
}
}
response.WriteJSON(w, records)
}
// SetCategoryPermissions persists specified category permissions
@ -348,7 +450,7 @@ func (h *Handler) SetCategoryPermissions(w http.ResponseWriter, r *http.Request)
return
}
var model = []permission.CategoryViewRequestModel{}
var model = []permission.CategoryRecord{}
err = json.Unmarshal(body, &model)
if err != nil {
response.WriteServerError(w, method, err)
@ -358,6 +460,7 @@ func (h *Handler) SetCategoryPermissions(w http.ResponseWriter, r *http.Request)
if !HasPermission(ctx, *h.Store, spaceID, permission.SpaceManage, permission.SpaceOwner) {
response.WriteForbiddenError(w)
h.Runtime.Log.Info("no permission to set category permissions")
return
}
@ -380,10 +483,10 @@ func (h *Handler) SetCategoryPermissions(w http.ResponseWriter, r *http.Request)
for _, m := range model {
perm := permission.Permission{}
perm.OrgID = ctx.OrgID
perm.Who = "user"
perm.WhoID = m.UserID
perm.Scope = "object"
perm.Location = "category"
perm.Who = m.Who
perm.WhoID = m.WhoID
perm.Scope = permission.ScopeRow
perm.Location = permission.LocationCategory
perm.RefID = m.CategoryID
perm.Action = permission.CategoryView
@ -418,9 +521,6 @@ func (h *Handler) GetDocumentPermissions(w http.ResponseWriter, r *http.Request)
response.WriteServerError(w, method, err)
return
}
if len(perms) == 0 {
perms = []permission.Permission{}
}
userPerms := make(map[string][]permission.Permission)
for _, p := range perms {
@ -451,9 +551,6 @@ func (h *Handler) GetUserDocumentPermissions(w http.ResponseWriter, r *http.Requ
response.WriteServerError(w, method, err)
return
}
if len(perms) == 0 {
perms = []permission.Permission{}
}
record := permission.DecodeUserDocumentPermissions(perms)
response.WriteJSON(w, record)
@ -483,11 +580,6 @@ func (h *Handler) SetDocumentPermissions(w http.ResponseWriter, r *http.Request)
return
}
// if !HasPermission(ctx, *h.Store, doc.LabelID, permission.SpaceManage, permission.SpaceOwner) {
// response.WriteForbiddenError(w)
// return
// }
defer streamutil.Close(r.Body)
body, err := ioutil.ReadAll(r.Body)
if err != nil {
@ -547,17 +639,37 @@ func (h *Handler) SetDocumentPermissions(w http.ResponseWriter, r *http.Request)
return
}
url := ctx.GetAppURL(fmt.Sprintf("s/%s/%s/d/%s/%s",
sp.RefID, stringutil.MakeSlug(sp.Name), doc.RefID, stringutil.MakeSlug(doc.Title)))
url := ctx.GetAppURL(fmt.Sprintf("s/%s/%s/d/%s/%s", sp.RefID, stringutil.MakeSlug(sp.Name), doc.RefID, stringutil.MakeSlug(doc.Title)))
// Permissions can be assigned to both groups and individual users.
// Pre-fetch users with group membership to help us work out
// if user belongs to a group with permissions.
groupMembers, err := h.Store.Group.GetMembers(ctx)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
for _, perm := range model {
perm.OrgID = ctx.OrgID
perm.DocumentID = id
// get group records for just this group
isGroup := perm.Who == permission.GroupPermission
groupRecords := []group.Record{}
if isGroup {
groupRecords = group.FilterGroupRecords(groupMembers, perm.WhoID)
}
// Only persist if there is a role!
if permission.HasAnyDocumentPermission(perm) {
r := permission.EncodeUserDocumentPermissions(perm)
if perm.WhoID == "" {
perm.WhoID = user.EveryoneUserID
}
r := permission.EncodeUserDocumentPermissions(perm)
for _, p := range r {
err = h.Store.Permission.AddPermission(ctx, p)
if err != nil {
@ -566,19 +678,32 @@ func (h *Handler) SetDocumentPermissions(w http.ResponseWriter, r *http.Request)
}
// Send email notification to users who have been given document approver role
if _, isExisting := previousRoleUsers[perm.UserID]; !isExisting {
if _, isExisting := previousRoleUsers[perm.WhoID]; !isExisting {
// we skip 'everyone' as it has no email address!
if perm.WhoID != user.EveryoneUserID && perm.DocumentRoleApprove {
whoToEmail := []string{}
// we skip 'everyone' (user id != empty string)
if perm.UserID != "0" && perm.UserID != "" && perm.DocumentRoleApprove {
existingUser, err := h.Store.User.Get(ctx, perm.UserID)
if err != nil {
response.WriteServerError(w, method, err)
break
if isGroup {
// send email to each group member
for i := range groupRecords {
whoToEmail = append(whoToEmail, groupRecords[i].UserID)
}
} else {
// send email to individual user
whoToEmail = append(whoToEmail, perm.WhoID)
}
mailer := mail.Mailer{Runtime: h.Runtime, Store: h.Store, Context: ctx}
go mailer.DocumentApprover(existingUser.Email, inviter.Fullname(), url, doc.Title)
h.Runtime.Log.Info(fmt.Sprintf("%s has made %s document approver for: %s", inviter.Email, existingUser.Email, doc.Title))
for i := range whoToEmail {
existingUser, err := h.Store.User.Get(ctx, whoToEmail[i])
if err != nil {
h.Runtime.Log.Error(method, err)
continue
}
mailer := mail.Mailer{Runtime: h.Runtime, Store: h.Store, Context: ctx}
go mailer.DocumentApprover(existingUser.Email, inviter.Fullname(), url, doc.Title)
h.Runtime.Log.Info(fmt.Sprintf("%s has made %s document approver for: %s", inviter.Email, existingUser.Email, doc.Title))
}
}
}
}

View file

@ -35,7 +35,7 @@ func (s Scope) AddPermission(ctx domain.RequestContext, r permission.Permission)
r.Created = time.Now().UTC()
_, err = ctx.Transaction.Exec("INSERT INTO permission (orgid, who, whoid, action, scope, location, refid, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
r.OrgID, r.Who, r.WhoID, string(r.Action), r.Scope, r.Location, r.RefID, r.Created)
r.OrgID, string(r.Who), r.WhoID, string(r.Action), string(r.Scope), string(r.Location), r.RefID, r.Created)
if err != nil {
err = errors.Wrap(err, "unable to execute insert permission")
@ -48,26 +48,34 @@ func (s Scope) AddPermission(ctx domain.RequestContext, r permission.Permission)
func (s Scope) AddPermissions(ctx domain.RequestContext, r permission.Permission, actions ...permission.Action) (err error) {
for _, a := range actions {
r.Action = a
s.AddPermission(ctx, r)
err := s.AddPermission(ctx, r)
if err != nil {
return err
}
}
return
}
// GetUserSpacePermissions returns space permissions for user.
// Context is used to for user ID.
// Context is used to for userID because must match by userID
// or everyone ID of 0.
func (s Scope) GetUserSpacePermissions(ctx domain.RequestContext, spaceID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='space' AND refid=? AND who='user' AND (whoid=? OR whoid='0')
FROM permission
WHERE orgid=? AND location='space' AND refid=? AND who='user' AND (whoid=? OR whoid='0')
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p LEFT JOIN rolemember r ON p.whoid=r.roleid WHERE p.orgid=? AND p.location='space' AND refid=?
AND p.who='role' AND (r.userid=? OR r.userid='0')`,
FROM permission p
LEFT JOIN rolemember r ON p.whoid=r.roleid
WHERE p.orgid=? AND p.location='space' AND refid=? AND p.who='role' AND (r.userid=? OR r.userid='0')`,
ctx.OrgID, spaceID, ctx.UserID, ctx.OrgID, spaceID, ctx.UserID)
if err == sql.ErrNoRows {
err = nil
r = []permission.Permission{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select user permissions %s", ctx.UserID))
@ -77,18 +85,21 @@ func (s Scope) GetUserSpacePermissions(ctx domain.RequestContext, spaceID string
}
// GetSpacePermissions returns space permissions for all users.
// We do not filter by userID because we return permissions for all users.
func (s Scope) GetSpacePermissions(ctx domain.RequestContext, spaceID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='space' AND refid=? AND who='user'
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p LEFT JOIN rolemember r ON p.whoid=r.roleid WHERE p.orgid=? AND p.location='space' AND p.refid=?
AND p.who='role'`,
FROM permission p
LEFT JOIN rolemember r ON p.whoid=r.roleid
WHERE p.orgid=? AND p.location='space' AND p.refid=? AND p.who='role'`,
ctx.OrgID, spaceID, ctx.OrgID, spaceID)
if err == sql.ErrNoRows {
err = nil
r = []permission.Permission{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select space permissions %s", ctx.UserID))
@ -97,6 +108,138 @@ func (s Scope) GetSpacePermissions(ctx domain.RequestContext, spaceID string) (r
return
}
// GetCategoryPermissions returns category permissions for all users.
func (s Scope) GetCategoryPermissions(ctx domain.RequestContext, catID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission
WHERE orgid=? AND location='category' AND who='user' AND (refid=? OR refid='0')
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p
LEFT JOIN rolemember r ON p.whoid=r.roleid
WHERE p.orgid=? AND p.location='category' AND p.who='role' AND (p.refid=? OR p.refid='0')`,
ctx.OrgID, catID, ctx.OrgID, catID)
if err == sql.ErrNoRows || len(r) == 0 {
err = nil
r = []permission.Permission{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select category permissions %s", catID))
}
return
}
// GetCategoryUsers returns space permissions for all users.
func (s Scope) GetCategoryUsers(ctx domain.RequestContext, catID string) (u []user.User, err error) {
err = s.Runtime.Db.Select(&u, `
SELECT u.id, IFNULL(u.refid, '') AS refid, IFNULL(u.firstname, '') AS firstname, IFNULL(u.lastname, '') as lastname, u.email, u.initials, u.password, u.salt, u.reset, u.created, u.revised
FROM user u LEFT JOIN account a ON u.refid = a.userid
WHERE a.orgid=? AND a.active=1 AND u.refid IN (
SELECT whoid from permission
WHERE orgid=? AND who='user' AND location='category' AND refid=?
UNION ALL
SELECT r.userid from rolemember r
LEFT JOIN permission p ON p.whoid=r.roleid
WHERE p.orgid=? AND p.who='role' AND p.location='category' AND p.refid=?
)
GROUP by u.id
ORDER BY firstname, lastname`,
ctx.OrgID, ctx.OrgID, catID, ctx.OrgID, catID)
if err == sql.ErrNoRows || len(u) == 0 {
err = nil
u = []user.User{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select users for category %s", catID))
}
return
}
// GetUserCategoryPermissions returns category permissions for given user.
func (s Scope) GetUserCategoryPermissions(ctx domain.RequestContext, userID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='category' AND who='user' AND (whoid=? OR whoid='0')
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p
LEFT JOIN rolemember r ON p.whoid=r.roleid
WHERE p.orgid=? AND p.location='category' AND p.who='role' AND (r.userid=? OR r.userid='0')`,
ctx.OrgID, userID, ctx.OrgID, userID)
if err == sql.ErrNoRows || len(r) == 0 {
err = nil
r = []permission.Permission{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select category permissions for user %s", userID))
}
return
}
// GetUserDocumentPermissions returns document permissions for user.
// Context is used to for user ID.
func (s Scope) GetUserDocumentPermissions(ctx domain.RequestContext, documentID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='document' AND refid=? AND who='user' AND (whoid=? OR whoid='0')
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p
LEFT JOIN rolemember r ON p.whoid=r.roleid
WHERE p.orgid=? AND p.location='document' AND refid=? AND p.who='role' AND (r.userid=? OR r.userid='0')`,
ctx.OrgID, documentID, ctx.UserID, ctx.OrgID, documentID, ctx.OrgID)
if err == sql.ErrNoRows {
err = nil
r = []permission.Permission{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select user document permissions %s", ctx.UserID))
}
return
}
// GetDocumentPermissions returns documents permissions for all users.
// We do not filter by userID because we return permissions for all users.
func (s Scope) GetDocumentPermissions(ctx domain.RequestContext, documentID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='document' AND refid=? AND who='user'
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p
LEFT JOIN rolemember r ON p.whoid=r.roleid
WHERE p.orgid=? AND p.location='document' AND p.refid=? AND p.who='role'`,
ctx.OrgID, documentID, ctx.OrgID, documentID)
if err == sql.ErrNoRows {
err = nil
r = []permission.Permission{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select document permissions %s", ctx.UserID))
}
return
}
// DeleteDocumentPermissions removes records from permissions table for given document.
func (s Scope) DeleteDocumentPermissions(ctx domain.RequestContext, documentID string) (rows int64, err error) {
b := mysql.BaseQuery{}
sql := fmt.Sprintf("DELETE FROM permission WHERE orgid='%s' AND location='document' AND refid='%s'", ctx.OrgID, documentID)
return b.DeleteWhere(ctx.Transaction, sql)
}
// DeleteSpacePermissions removes records from permissions table for given space ID.
func (s Scope) DeleteSpacePermissions(ctx domain.RequestContext, spaceID string) (rows int64, err error) {
b := mysql.BaseQuery{}
@ -146,121 +289,3 @@ func (s Scope) DeleteSpaceCategoryPermissions(ctx domain.RequestContext, spaceID
return b.DeleteWhere(ctx.Transaction, sql)
}
// GetCategoryPermissions returns category permissions for all users.
func (s Scope) GetCategoryPermissions(ctx domain.RequestContext, catID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='category' AND refid=? AND who='user'
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p LEFT JOIN rolemember r ON p.whoid=r.roleid WHERE p.orgid=? AND p.location='space' AND p.refid=?
AND p.who='role'`,
ctx.OrgID, catID, ctx.OrgID, catID)
if err == sql.ErrNoRows {
err = nil
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select category permissions %s", catID))
}
return
}
// GetCategoryUsers returns space permissions for all users.
func (s Scope) GetCategoryUsers(ctx domain.RequestContext, catID string) (u []user.User, err error) {
err = s.Runtime.Db.Select(&u, `
SELECT u.id, IFNULL(u.refid, '') AS refid, IFNULL(u.firstname, '') AS firstname, IFNULL(u.lastname, '') as lastname, u.email, u.initials, u.password, u.salt, u.reset, u.created, u.revised
FROM user u LEFT JOIN account a ON u.refid = a.userid
WHERE a.orgid=? AND a.active=1 AND u.refid IN (
SELECT whoid from permission WHERE orgid=? AND who='user' AND location='category' AND refid=? UNION ALL
SELECT r.userid from rolemember r LEFT JOIN permission p ON p.whoid=r.roleid WHERE p.orgid=? AND p.who='role'
AND p.location='category' AND p.refid=?
)
GROUP by u.id
ORDER BY firstname, lastname`,
ctx.OrgID, ctx.OrgID, catID, ctx.OrgID, catID)
if err == sql.ErrNoRows {
err = nil
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select users for category %s", catID))
}
return
}
// GetUserCategoryPermissions returns category permissions for given user.
func (s Scope) GetUserCategoryPermissions(ctx domain.RequestContext, userID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='category' AND who='user' AND (whoid=? OR whoid='0')
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p LEFT JOIN rolemember r ON p.whoid=r.roleid
WHERE p.orgid=? AND p.location='category' AND p.who='role'`,
ctx.OrgID, userID, ctx.OrgID)
if err == sql.ErrNoRows {
err = nil
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select category permissions for user %s", userID))
}
return
}
// GetUserDocumentPermissions returns document permissions for user.
// Context is used to for user ID.
func (s Scope) GetUserDocumentPermissions(ctx domain.RequestContext, documentID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='document' AND refid=? AND who='user' AND (whoid=? OR whoid='0')
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p LEFT JOIN rolemember r ON p.whoid=r.roleid WHERE p.orgid=? AND p.location='document' AND refid=?
AND p.who='role' AND (r.userid=? OR r.userid='0')`,
ctx.OrgID, documentID, ctx.UserID, ctx.OrgID, documentID, ctx.OrgID)
if err == sql.ErrNoRows {
err = nil
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select user document permissions %s", ctx.UserID))
}
return
}
// GetDocumentPermissions returns documents permissions for all users.
func (s Scope) GetDocumentPermissions(ctx domain.RequestContext, documentID string) (r []permission.Permission, err error) {
err = s.Runtime.Db.Select(&r, `
SELECT id, orgid, who, whoid, action, scope, location, refid
FROM permission WHERE orgid=? AND location='document' AND refid=? AND who='user'
UNION ALL
SELECT p.id, p.orgid, p.who, p.whoid, p.action, p.scope, p.location, p.refid
FROM permission p LEFT JOIN rolemember r ON p.whoid=r.roleid WHERE p.orgid=? AND p.location='document' AND p.refid=?
AND p.who='role'`,
ctx.OrgID, documentID, ctx.OrgID, documentID)
if err == sql.ErrNoRows {
err = nil
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("unable to execute select document permissions %s", ctx.UserID))
}
return
}
// DeleteDocumentPermissions removes records from permissions table for given document.
func (s Scope) DeleteDocumentPermissions(ctx domain.RequestContext, documentID string) (rows int64, err error) {
b := mysql.BaseQuery{}
sql := fmt.Sprintf("DELETE FROM permission WHERE orgid='%s' AND location='document' AND refid='%s'", ctx.OrgID, documentID)
return b.DeleteWhere(ctx.Transaction, sql)
}

View file

@ -15,6 +15,7 @@ import (
"database/sql"
"github.com/documize/community/domain"
group "github.com/documize/community/model/group"
pm "github.com/documize/community/model/permission"
u "github.com/documize/community/model/user"
)
@ -30,7 +31,7 @@ func CanViewSpaceDocument(ctx domain.RequestContext, s domain.Store, labelID str
}
for _, role := range roles {
if role.RefID == labelID && role.Location == "space" && role.Scope == "object" &&
if role.RefID == labelID && role.Location == pm.LocationSpace && role.Scope == pm.ScopeRow &&
pm.ContainsPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) {
return true
}
@ -58,7 +59,7 @@ func CanViewDocument(ctx domain.RequestContext, s domain.Store, documentID strin
}
for _, role := range roles {
if role.RefID == document.LabelID && role.Location == "space" && role.Scope == "object" &&
if role.RefID == document.LabelID && role.Location == pm.LocationSpace && role.Scope == pm.ScopeRow &&
pm.ContainsPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) {
return true
}
@ -88,7 +89,7 @@ func CanChangeDocument(ctx domain.RequestContext, s domain.Store, documentID str
}
for _, role := range roles {
if role.RefID == document.LabelID && role.Location == "space" && role.Scope == "object" && role.Action == pm.DocumentEdit {
if role.RefID == document.LabelID && role.Location == pm.LocationSpace && role.Scope == pm.ScopeRow && role.Action == pm.DocumentEdit {
return true
}
}
@ -136,7 +137,7 @@ func CanUploadDocument(ctx domain.RequestContext, s domain.Store, spaceID string
}
for _, role := range roles {
if role.RefID == spaceID && role.Location == "space" && role.Scope == "object" &&
if role.RefID == spaceID && role.Location == pm.LocationSpace && role.Scope == pm.ScopeRow &&
pm.ContainsPermission(role.Action, pm.DocumentAdd) {
return true
}
@ -155,7 +156,7 @@ func CanViewSpace(ctx domain.RequestContext, s domain.Store, spaceID string) boo
return false
}
for _, role := range roles {
if role.RefID == spaceID && role.Location == "space" && role.Scope == "object" &&
if role.RefID == spaceID && role.Location == pm.LocationSpace && role.Scope == pm.ScopeRow &&
pm.ContainsPermission(role.Action, pm.SpaceView, pm.SpaceManage, pm.SpaceOwner) {
return true
}
@ -176,7 +177,7 @@ func HasPermission(ctx domain.RequestContext, s domain.Store, spaceID string, ac
}
for _, role := range roles {
if role.RefID == spaceID && role.Location == "space" && role.Scope == "object" {
if role.RefID == spaceID && role.Location == pm.LocationSpace && role.Scope == pm.ScopeRow {
for _, a := range actions {
if role.Action == a {
return true
@ -193,34 +194,63 @@ func GetDocumentApprovers(ctx domain.RequestContext, s domain.Store, spaceID, do
users = []u.User{}
prev := make(map[string]bool) // used to ensure we only process user once
// check space permissions
sp, err := s.Permission.GetSpacePermissions(ctx, spaceID)
for _, p := range sp {
if p.Action == pm.DocumentApprove {
user, err := s.User.Get(ctx, p.WhoID)
if err == nil {
prev[user.RefID] = true
users = append(users, user)
} else {
return users, err
}
}
// Permissions can be assigned to both groups and individual users.
// Pre-fetch users with group membership to help us work out
// if user belongs to a group with permissions.
groupMembers, err := s.Group.GetMembers(ctx)
if err != nil {
return users, err
}
// check document permissions
// space permissions
sp, err := s.Permission.GetSpacePermissions(ctx, spaceID)
if err != nil {
return users, err
}
// document permissions
dp, err := s.Permission.GetDocumentPermissions(ctx, documentID)
for _, p := range dp {
if p.Action == pm.DocumentApprove {
user, err := s.User.Get(ctx, p.WhoID)
if err == nil {
if err != nil {
return users, err
}
// all permissions
all := sp
all = append(all, dp...)
for _, p := range all {
// only approvers
if p.Action != pm.DocumentApprove {
continue
}
if p.Who == pm.GroupPermission {
// get group records for just this group
groupRecords := group.FilterGroupRecords(groupMembers, p.WhoID)
for i := range groupRecords {
user, err := s.User.Get(ctx, groupRecords[i].UserID)
if err != nil {
return users, err
}
if _, isExisting := prev[user.RefID]; !isExisting {
users = append(users, user)
prev[user.RefID] = true
}
} else {
}
}
if p.Who == pm.UserPermission {
user, err := s.User.Get(ctx, p.WhoID)
if err != nil {
return users, err
}
if _, isExisting := prev[user.RefID]; !isExisting {
users = append(users, user)
prev[user.RefID] = true
}
}
}
return
return users, err
}

View file

@ -15,6 +15,7 @@ package setting
import (
"encoding/json"
"encoding/xml"
"fmt"
"io/ioutil"
"net/http"
@ -22,6 +23,7 @@ import (
"github.com/documize/community/core/event"
"github.com/documize/community/core/response"
"github.com/documize/community/domain"
"github.com/documize/community/domain/smtp"
"github.com/documize/community/model/audit"
)
@ -91,7 +93,37 @@ func (h *Handler) SetSMTP(w http.ResponseWriter, r *http.Request) {
h.Store.Audit.Record(ctx, audit.EventTypeSystemSMTP)
response.WriteEmpty(w)
// test connection
var result struct {
Success bool `json:"success"`
Message string `json:"message"`
}
result.Message = "Email sent successfully!"
u, err := h.Store.User.Get(ctx, ctx.UserID)
if err != nil {
result.Success = false
result.Message = err.Error()
h.Runtime.Log.Error(method, err)
response.WriteJSON(w, result)
return
}
cfg := GetSMTPConfig(h.Store)
dialer, err := smtp.Connect(cfg)
em := smtp.EmailMessage{}
em.Subject = "Documize SMTP Test"
em.BodyHTML = "<p>This is a test email from Documize using current SMTP settings.</p>"
em.ToEmail = u.Email
em.ToName = u.Fullname()
result.Success, err = smtp.SendMessage(dialer, cfg, em)
if !result.Success {
result.Message = fmt.Sprintf("Unable to send test email: %s", err.Error())
}
response.WriteJSON(w, result)
}
// License returns product license

60
domain/setting/smtp.go Normal file
View file

@ -0,0 +1,60 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
// Package setting manages both global and user level settings
package setting
import (
"strconv"
"github.com/documize/community/domain"
"github.com/documize/community/domain/smtp"
)
// GetSMTPConfig returns SMTP configuration.
func GetSMTPConfig(s *domain.Store) (c smtp.Config) {
c = smtp.Config{}
// server
c.Host, _ = s.Setting.Get("SMTP", "host")
port, _ := s.Setting.Get("SMTP", "port")
c.Port, _ = strconv.Atoi(port)
// credentials
c.Username, _ = s.Setting.Get("SMTP", "userid")
c.Password, _ = s.Setting.Get("SMTP", "password")
// sender
c.SenderEmail, _ = s.Setting.Get("SMTP", "sender")
c.SenderName, _ = s.Setting.Get("SMTP", "senderName")
if c.SenderName == "" {
c.SenderName = "Documize"
}
// anon auth?
anon, _ := s.Setting.Get("SMTP", "anonymous")
c.AnonymousAuth, _ = strconv.ParseBool(anon)
// base64 encode creds?
b64, _ := s.Setting.Get("SMTP", "base64creds")
c.Base64EncodeCredentials, _ = strconv.ParseBool(b64)
// SSL?
ssl, _ := s.Setting.Get("SMTP", "usessl")
c.UseSSL, _ = strconv.ParseBool(ssl)
// verify SSL?
verifySSL, _ := s.Setting.Get("SMTP", "verifyssl")
c.SkipSSLVerify, _ = strconv.ParseBool(verifySSL)
c.SkipSSLVerify = true
return
}

114
domain/smtp/send.go Normal file
View file

@ -0,0 +1,114 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
// Package smtp provides access to SMTP server for sending email.
package smtp
import (
"crypto/tls"
"encoding/base64"
"strings"
"github.com/documize/community/core/mail"
)
// Config is used to create SMTP server connection
type Config struct {
// IP/hostname of SMTP server
Host string
// Port number of SMTP server
Port int
// Username for authentication with SMTP server
Username string
// Password for authentication with SMTP server
Password string
// SenderEmail is FROM address
SenderEmail string
// SenderName is FROM display name
SenderName string
// AnonymousAuth does not send username/password to server
AnonymousAuth bool
// Base64EncodeCredentials encodes User and Password as base64 before sending to SMTP server
Base64EncodeCredentials bool
// UseSSL uses SMTP SSL connection with SMTP server
UseSSL bool
// SkipSSLVerify allows unverified certificates
SkipSSLVerify bool
}
// Connect returns open connection to server for sending email
func Connect(c Config) (d *mail.Dialer, err error) {
// prepare credentials
u := strings.TrimSpace(c.Username)
p := strings.TrimSpace(c.Password)
// anonymous, no credentials
if c.AnonymousAuth {
u = ""
p = ""
}
// base64 encode if required
if c.Base64EncodeCredentials {
u = base64.StdEncoding.EncodeToString([]byte(u))
p = base64.StdEncoding.EncodeToString([]byte(p))
}
// basic server
d = mail.NewDialer(c.Host, c.Port, u, p)
// use SSL
d.SSL = c.UseSSL
// verify SSL cert chain
d.TLSConfig = &tls.Config{InsecureSkipVerify: c.SkipSSLVerify}
// TLS mode
d.StartTLSPolicy = mail.OpportunisticStartTLS
return d, nil
}
// EmailMessage represents email to be sent.
type EmailMessage struct {
ToEmail string
ToName string
Subject string
BodyHTML string
}
// SendMessage sends email using specified SMTP connection
func SendMessage(d *mail.Dialer, c Config, em EmailMessage) (b bool, err error) {
m := mail.NewMessage()
// participants
m.SetHeader("From", m.FormatAddress(c.SenderEmail, c.SenderName))
m.SetHeader("To", m.FormatAddress(em.ToEmail, em.ToName))
// content
m.SetHeader("Subject", em.Subject)
m.SetBody("text/html", em.BodyHTML)
// send email
if err = d.DialAndSend(m); err != nil {
return false, err
}
return true, nil
}

View file

@ -110,10 +110,10 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
perm := permission.Permission{}
perm.OrgID = sp.OrgID
perm.Who = "user"
perm.Who = permission.UserPermission
perm.WhoID = ctx.UserID
perm.Scope = "object"
perm.Location = "space"
perm.Scope = permission.ScopeRow
perm.Location = permission.LocationSpace
perm.RefID = sp.RefID
perm.Action = "" // we send array for actions below
@ -800,10 +800,10 @@ func (h *Handler) Invite(w http.ResponseWriter, r *http.Request) {
perm := permission.Permission{}
perm.OrgID = sp.OrgID
perm.Who = "user"
perm.Who = permission.UserPermission
perm.WhoID = u.RefID
perm.Scope = "object"
perm.Location = "space"
perm.Scope = permission.ScopeRow
perm.Location = permission.LocationSpace
perm.RefID = sp.RefID
perm.Action = "" // we send array for actions below

View file

@ -64,10 +64,10 @@ func inviteNewUserToSharedSpace(ctx domain.RequestContext, rt *env.Runtime, s *d
perm := permission.Permission{}
perm.OrgID = sp.OrgID
perm.Who = "user"
perm.Who = permission.UserPermission
perm.WhoID = userID
perm.Scope = "object"
perm.Location = "space"
perm.Scope = permission.ScopeRow
perm.Location = permission.LocationSpace
perm.RefID = sp.RefID
perm.Action = "" // we send array for actions below

View file

@ -42,8 +42,8 @@ func TestSpace(t *testing.T) {
perm.OrgID = ctx.OrgID
perm.Who = "user"
perm.WhoID = ctx.UserID
perm.Scope = "object"
perm.Location = "space"
perm.Scope = permission.ScopeRow
perm.Location = permission.LocationSpace
perm.RefID = spaceID
perm.Action = "" // we send array for actions below
@ -109,8 +109,8 @@ func TestSpace(t *testing.T) {
perm.OrgID = ctx.OrgID
perm.Who = "user"
perm.WhoID = ctx.UserID
perm.Scope = "object"
perm.Location = "space"
perm.Scope = permission.ScopeRow
perm.Location = permission.LocationSpace
perm.RefID = spaceID2
perm.Action = "" // we send array for actions below
@ -170,8 +170,8 @@ func TestSpace(t *testing.T) {
perm.OrgID = ctx.OrgID
perm.Who = "user"
perm.WhoID = ctx.UserID
perm.Scope = "object"
perm.Location = "space"
perm.Scope = permission.ScopeRow
perm.Location = permission.LocationSpace
perm.RefID = spaceID
perm.Action = "" // we send array for actions below

View file

@ -20,6 +20,7 @@ import (
"github.com/documize/community/model/block"
"github.com/documize/community/model/category"
"github.com/documize/community/model/doc"
"github.com/documize/community/model/group"
"github.com/documize/community/model/link"
"github.com/documize/community/model/org"
"github.com/documize/community/model/page"
@ -39,6 +40,7 @@ type Store struct {
Block BlockStorer
Category CategoryStorer
Document DocumentStorer
Group GroupStorer
Link LinkStorer
Organization OrganizationStorer
Page PageStorer
@ -108,7 +110,7 @@ type UserStorer interface {
GetByToken(ctx RequestContext, token string) (u user.User, err error)
GetBySerial(ctx RequestContext, serial string) (u user.User, err error)
GetActiveUsersForOrganization(ctx RequestContext) (u []user.User, err error)
GetUsersForOrganization(ctx RequestContext) (u []user.User, err error)
GetUsersForOrganization(ctx RequestContext, filter string) (u []user.User, err error)
GetSpaceUsers(ctx RequestContext, spaceID string) (u []user.User, err error)
GetUsersForSpaces(ctx RequestContext, spaces []string) (u []user.User, err error)
UpdateUser(ctx RequestContext, u user.User) (err error)
@ -116,6 +118,7 @@ type UserStorer interface {
DeactiveUser(ctx RequestContext, userID string) (err error)
ForgotUserPassword(ctx RequestContext, email, token string) (err error)
CountActiveUsers() (c int)
MatchUsers(ctx RequestContext, text string, maxMatches int) (u []user.User, err error)
}
// AccountStorer defines required methods for account management
@ -265,3 +268,16 @@ type PageStorer interface {
GetDocumentRevisions(ctx RequestContext, documentID string) (revisions []page.Revision, err error)
DeletePageRevisions(ctx RequestContext, pageID string) (rows int64, err error)
}
// GroupStorer defines required methods for persisting user groups and memberships
type GroupStorer interface {
Add(ctx RequestContext, g group.Group) (err error)
Get(ctx RequestContext, refID string) (g group.Group, err error)
GetAll(ctx RequestContext) (g []group.Group, err error)
Update(ctx RequestContext, g group.Group) (err error)
Delete(ctx RequestContext, refID string) (rows int64, err error)
GetGroupMembers(ctx RequestContext, groupID string) (m []group.Member, err error)
GetMembers(ctx RequestContext) (r []group.Record, err error)
JoinGroup(ctx RequestContext, groupID, userID string) (err error)
LeaveGroup(ctx RequestContext, groupID, userID string) (err error)
}

View file

@ -13,6 +13,7 @@ package user
import (
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"io/ioutil"
@ -34,6 +35,7 @@ import (
"github.com/documize/community/domain/organization"
"github.com/documize/community/model/account"
"github.com/documize/community/model/audit"
"github.com/documize/community/model/group"
"github.com/documize/community/model/user"
)
@ -207,7 +209,6 @@ func (h *Handler) Add(w http.ResponseWriter, r *http.Request) {
go mailer.InviteNewUser(userModel.Email, inviter.Fullname(), url, userModel.Email, requestedPassword)
h.Runtime.Log.Info(fmt.Sprintf("%s invited by %s on %s", userModel.Email, inviter.Email, ctx.AppURL))
} else {
mailer := mail.Mailer{Runtime: h.Runtime, Store: h.Store, Context: ctx}
go mailer.InviteExistingUser(userModel.Email, inviter.Fullname(), ctx.GetAppURL(""))
@ -228,6 +229,8 @@ func (h *Handler) GetOrganizationUsers(w http.ResponseWriter, r *http.Request) {
return
}
filter := request.Query(r, "filter")
active, err := strconv.ParseBool(request.Query(r, "active"))
if err != nil {
active = false
@ -243,20 +246,33 @@ func (h *Handler) GetOrganizationUsers(w http.ResponseWriter, r *http.Request) {
return
}
} else {
u, err = h.Store.User.GetUsersForOrganization(ctx)
u, err = h.Store.User.GetUsersForOrganization(ctx, filter)
if err != nil && err != sql.ErrNoRows {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
}
if len(u) == 0 {
u = []user.User{}
// prefetch all group membership records
groups, err := h.Store.Group.GetMembers(ctx)
if err != nil && err != sql.ErrNoRows {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
// for each user...
for i := range u {
// 1. attach user accounts
AttachUserAccounts(ctx, *h.Store, ctx.OrgID, &u[i])
// 2. attach user groups
u[i].Groups = []group.Record{}
for j := range groups {
if groups[j].UserID == u[i].RefID {
u[i].Groups = append(u[i].Groups, groups[j])
}
}
}
response.WriteJSON(w, u)
@ -594,7 +610,7 @@ func (h *Handler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
// ResetPassword stores the newly chosen password for the user.
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
method := "user.ForgotUserPassword"
method := "user.ResetPassword"
ctx := domain.GetRequestContext(r)
ctx.Subdomain = organization.GetSubdomainFromHost(r)
@ -644,3 +660,191 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
response.WriteEmpty(w)
}
// MatchUsers returns users where provided text
// matches firstname, lastname, email
func (h *Handler) MatchUsers(w http.ResponseWriter, r *http.Request) {
method := "user.MatchUsers"
ctx := domain.GetRequestContext(r)
defer streamutil.Close(r.Body)
body, err := ioutil.ReadAll(r.Body)
if err != nil {
response.WriteBadRequestError(w, method, "text")
h.Runtime.Log.Error(method, err)
return
}
searchText := string(body)
u, err := h.Store.User.MatchUsers(ctx, searchText, 100)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
response.WriteJSON(w, u)
}
// BulkImport imports comma-delimited list of users:
// firstname, lastname, email
func (h *Handler) BulkImport(w http.ResponseWriter, r *http.Request) {
method := "user.BulkImport"
ctx := domain.GetRequestContext(r)
if !ctx.Administrator {
response.WriteForbiddenError(w)
return
}
defer streamutil.Close(r.Body)
body, err := ioutil.ReadAll(r.Body)
if err != nil {
response.WriteBadRequestError(w, method, "text")
h.Runtime.Log.Error(method, err)
return
}
usersList := string(body)
cr := csv.NewReader(strings.NewReader(usersList))
cr.TrimLeadingSpace = true
cr.FieldsPerRecord = 3
records, err := cr.ReadAll()
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
ctx.Transaction, err = h.Runtime.Db.Beginx()
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
inviter, err := h.Store.User.Get(ctx, ctx.UserID)
if err != nil {
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
for _, v := range records {
userModel := user.User{}
userModel.Firstname = strings.TrimSpace(v[0])
userModel.Lastname = strings.TrimSpace(v[1])
userModel.Email = strings.ToLower(strings.TrimSpace(v[2]))
if len(userModel.Email) == 0 || len(userModel.Firstname) == 0 || len(userModel.Lastname) == 0 {
h.Runtime.Log.Info(method + " missing firstname, lastname, or email")
continue
}
userModel.Initials = stringutil.MakeInitials(userModel.Firstname, userModel.Lastname)
requestedPassword := secrets.GenerateRandomPassword()
userModel.Salt = secrets.GenerateSalt()
userModel.Password = secrets.GeneratePassword(requestedPassword, userModel.Salt)
// only create account if not dupe
addUser := true
addAccount := true
var userID string
userDupe, err := h.Store.User.GetByEmail(ctx, userModel.Email)
if err != nil && err != sql.ErrNoRows {
h.Runtime.Log.Error(method, err)
continue
}
if userModel.Email == userDupe.Email {
addUser = false
userID = userDupe.RefID
h.Runtime.Log.Info("Dupe user found, will not add " + userModel.Email)
}
if addUser {
userID = uniqueid.Generate()
userModel.RefID = userID
err = h.Store.User.Add(ctx, userModel)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
h.Runtime.Log.Info("Adding user")
} else {
AttachUserAccounts(ctx, *h.Store, ctx.OrgID, &userDupe)
for _, a := range userDupe.Accounts {
if a.OrgID == ctx.OrgID {
addAccount = false
h.Runtime.Log.Info("Dupe account found, will not add")
break
}
}
}
// set up user account for the org
if addAccount {
var a account.Account
a.RefID = uniqueid.Generate()
a.UserID = userID
a.OrgID = ctx.OrgID
a.Editor = true
a.Admin = false
a.Active = true
err = h.Store.Account.Add(ctx, a)
if err != nil {
ctx.Transaction.Rollback()
response.WriteServerError(w, method, err)
h.Runtime.Log.Error(method, err)
return
}
}
if addUser {
event.Handler().Publish(string(event.TypeAddUser))
h.Store.Audit.Record(ctx, audit.EventTypeUserAdd)
}
if addAccount {
event.Handler().Publish(string(event.TypeAddAccount))
h.Store.Audit.Record(ctx, audit.EventTypeAccountAdd)
}
// If we did not add user or give them access (account) then we error back
if !addUser && !addAccount {
h.Runtime.Log.Info(method + " duplicate user not added")
continue
}
// Invite new user and prepare invitation email (that contains SSO link)
if addUser && addAccount {
size := len(requestedPassword)
auth := fmt.Sprintf("%s:%s:%s", ctx.AppURL, userModel.Email, requestedPassword[:size])
encrypted := secrets.EncodeBase64([]byte(auth))
url := fmt.Sprintf("%s/%s", ctx.GetAppURL("auth/sso"), url.QueryEscape(string(encrypted)))
mailer := mail.Mailer{Runtime: h.Runtime, Store: h.Store, Context: ctx}
go mailer.InviteNewUser(userModel.Email, inviter.Fullname(), url, userModel.Email, requestedPassword)
h.Runtime.Log.Info(fmt.Sprintf("%s invited by %s on %s", userModel.Email, inviter.Email, ctx.AppURL))
} else {
mailer := mail.Mailer{Runtime: h.Runtime, Store: h.Store, Context: ctx}
go mailer.InviteExistingUser(userModel.Email, inviter.Fullname(), ctx.GetAppURL(""))
h.Runtime.Log.Info(fmt.Sprintf("%s is giving access to an existing user %s", inviter.Email, userModel.Email))
}
}
ctx.Transaction.Commit()
response.WriteEmpty(w)
}

View file

@ -14,6 +14,7 @@ package mysql
import (
"database/sql"
"fmt"
"strconv"
"strings"
"time"
@ -117,6 +118,10 @@ func (s Scope) GetActiveUsersForOrganization(ctx domain.RequestContext) (u []use
ORDER BY u.firstname,u.lastname`,
ctx.OrgID)
if err == sql.ErrNoRows || len(u) == 0 {
err = nil
u = []user.User{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("get active users by org %s", ctx.OrgID))
}
@ -126,13 +131,24 @@ func (s Scope) GetActiveUsersForOrganization(ctx domain.RequestContext) (u []use
// GetUsersForOrganization returns a slice containing all of the user records for the organizaiton
// identified in the Persister.
func (s Scope) GetUsersForOrganization(ctx domain.RequestContext) (u []user.User, err error) {
func (s Scope) GetUsersForOrganization(ctx domain.RequestContext, filter string) (u []user.User, err error) {
filter = strings.TrimSpace(strings.ToLower(filter))
likeQuery := ""
if len(filter) > 0 {
likeQuery = " AND (LOWER(u.firstname) LIKE '%" + filter + "%' OR LOWER(u.lastname) LIKE '%" + filter + "%' OR LOWER(u.email) LIKE '%" + filter + "%') "
}
err = s.Runtime.Db.Select(&u,
`SELECT u.id, u.refid, u.firstname, u.lastname, u.email, u.initials, u.password, u.salt, u.reset, u.created, u.revised,
u.global, a.active, a.editor, a.admin, a.users as viewusers
FROM user u, account a
WHERE u.refid=a.userid AND a.orgid=?
ORDER BY u.firstname, u.lastname`, ctx.OrgID)
WHERE u.refid=a.userid AND a.orgid=? `+likeQuery+
`ORDER BY u.firstname, u.lastname LIMIT 100`, ctx.OrgID)
if err == sql.ErrNoRows || len(u) == 0 {
err = nil
u = []user.User{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf(" get users for org %s", ctx.OrgID))
@ -154,6 +170,10 @@ func (s Scope) GetSpaceUsers(ctx domain.RequestContext, spaceID string) (u []use
ORDER BY u.firstname, u.lastname
`, ctx.OrgID, ctx.OrgID, spaceID, ctx.OrgID, spaceID)
if err == sql.ErrNoRows || len(u) == 0 {
err = nil
u = []user.User{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("get space users for org %s", ctx.OrgID))
}
@ -182,6 +202,10 @@ func (s Scope) GetUsersForSpaces(ctx domain.RequestContext, spaces []string) (u
query = s.Runtime.Db.Rebind(query)
err = s.Runtime.Db.Select(&u, query, args...)
if err == sql.ErrNoRows || len(u) == 0 {
err = nil
u = []user.User{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("get users for spaces for user %s", ctx.UserID))
}
@ -255,3 +279,30 @@ func (s Scope) CountActiveUsers() (c int) {
return
}
// MatchUsers returns users that have match to either firstname, lastname or email.
func (s Scope) MatchUsers(ctx domain.RequestContext, text string, maxMatches int) (u []user.User, err error) {
text = strings.TrimSpace(strings.ToLower(text))
likeQuery := ""
if len(text) > 0 {
likeQuery = " AND (LOWER(firstname) LIKE '%" + text + "%' OR LOWER(lastname) LIKE '%" + text + "%' OR LOWER(email) LIKE '%" + text + "%') "
}
err = s.Runtime.Db.Select(&u,
`SELECT u.id, u.refid, u.firstname, u.lastname, u.email, u.initials, u.password, u.salt, u.reset, u.created, u.revised,
u.global, a.active, a.editor, a.admin, a.users as viewusers
FROM user u, account a
WHERE a.orgid=? AND u.refid=a.userid AND a.active=1 `+likeQuery+
`ORDER BY u.firstname,u.lastname LIMIT `+strconv.Itoa(maxMatches),
ctx.OrgID)
if err == sql.ErrNoRows || len(u) == 0 {
err = nil
u = []user.User{}
}
if err != nil {
err = errors.Wrap(err, fmt.Sprintf("matching users for org %s", ctx.OrgID))
}
return
}

View file

@ -22,6 +22,7 @@ import (
block "github.com/documize/community/domain/block/mysql"
category "github.com/documize/community/domain/category/mysql"
doc "github.com/documize/community/domain/document/mysql"
group "github.com/documize/community/domain/group/mysql"
link "github.com/documize/community/domain/link/mysql"
org "github.com/documize/community/domain/organization/mysql"
page "github.com/documize/community/domain/page/mysql"
@ -42,6 +43,7 @@ func StoreMySQL(r *env.Runtime, s *domain.Store) {
s.Block = block.Scope{Runtime: r}
s.Category = category.Scope{Runtime: r}
s.Document = doc.Scope{Runtime: r}
s.Group = group.Scope{Runtime: r}
s.Link = link.Scope{Runtime: r}
s.Organization = org.Scope{Runtime: r}
s.Page = page.Scope{Runtime: r}

View file

@ -41,8 +41,8 @@ func main() {
// product details
rt.Product = env.ProdInfo{}
rt.Product.Major = "1"
rt.Product.Minor = "57"
rt.Product.Patch = "3"
rt.Product.Minor = "58"
rt.Product.Patch = "0"
rt.Product.Version = fmt.Sprintf("%s.%s.%s", rt.Product.Major, rt.Product.Minor, rt.Product.Patch)
rt.Product.Edition = "Community"
rt.Product.Title = fmt.Sprintf("%s Edition", rt.Product.Edition)

File diff suppressed because one or more lines are too long

View file

@ -17,8 +17,10 @@ export default Component.extend({
SMTPHostEmptyError: empty('model.smtp.host'),
SMTPPortEmptyError: empty('model.smtp.port'),
SMTPSenderEmptyError: empty('model.smtp.sender'),
SMTPUserIdEmptyError: empty('model.smtp.userid'),
SMTPPasswordEmptyError: empty('model.smtp.password'),
senderNameError: empty('model.smtp.senderName'),
buttonText: 'Save & Test',
testSMTP: null,
actions: {
saveSMTP() {
@ -34,16 +36,22 @@ export default Component.extend({
$("#smtp-sender").focus();
return;
}
if (this.get('SMTPUserIdEmptyError')) {
$("#smtp-userid").focus();
return;
}
if (this.get('SMTPPasswordEmptyError')) {
$("#smtp-password").focus();
if (this.get('senderNameError')) {
$("#smtp-senderName").focus();
return;
}
this.get('saveSMTP')().then(() => {
this.set('testSMTP', {
success: true,
message: ''
},
);
this.set('buttonText', 'Please wait...');
this.get('saveSMTP')().then((result) => {
this.set('buttonText', 'Save & Test');
this.set('testSMTP', result);
});
}
}

View file

@ -10,180 +10,64 @@
// https://documize.com
import $ from 'jquery';
import Component from '@ember/component';
import { schedule, debounce } from '@ember/runloop';
import AuthProvider from '../../mixins/auth';
import ModalMixin from '../../mixins/modal';
import Component from '@ember/component';
export default Component.extend(AuthProvider, ModalMixin, {
editUser: null,
deleteUser: null,
filter: '',
hasSelectedUsers: false,
showDeleteDialog: false,
bulkUsers: '',
newUser: null,
init() {
this._super(...arguments);
this.password = {};
this.filteredUsers = [];
this.selectedUsers = [];
this.set('newUser', { firstname: '', lastname: '', email: '', active: true });
},
didReceiveAttrs() {
this._super(...arguments);
let users = this.get('users');
users.forEach(user => {
user.set('me', user.get('id') === this.get('session.session.authenticated.user.id'));
user.set('selected', false);
});
this.set('users', users);
this.set('filteredUsers', users);
},
onKeywordChange: function () {
debounce(this, this.filterUsers, 350);
}.observes('filter'),
filterUsers() {
let users = this.get('users');
let filteredUsers = [];
let filter = this.get('filter').toLowerCase();
users.forEach(user => {
if (user.get('fullname').toLowerCase().includes(filter) || user.get('email').toLowerCase().includes(filter)) {
filteredUsers.pushObject(user);
}
});
this.set('filteredUsers', filteredUsers);
},
actions: {
toggleSelect(user) {
user.set('selected', !user.get('selected'));
let su = this.get('selectedUsers');
if (user.get('selected')) {
su.push(user.get('id'));
} else {
su = _.reject(su, function(id){ return id === user.get('id') });
}
this.set('selectedUsers', su);
this.set('hasSelectedUsers', su.length > 0);
onOpenUserModal() {
this.modalOpen("#add-user-modal", {"show": true}, '#newUserFirstname');
},
toggleActive(id) {
let user = this.users.findBy("id", id);
user.set('active', !user.get('active'));
let cb = this.get('onSave');
cb(user);
},
toggleEditor(id) {
let user = this.users.findBy("id", id);
user.set('editor', !user.get('editor'));
let cb = this.get('onSave');
cb(user);
},
toggleAdmin(id) {
let user = this.users.findBy("id", id);
user.set('admin', !user.get('admin'));
let cb = this.get('onSave');
cb(user);
},
toggleUsers(id) {
let user = this.users.findBy("id", id);
user.set('viewUsers', !user.get('viewUsers'));
let cb = this.get('onSave');
cb(user);
},
onShowEdit(id) {
let user = this.users.findBy("id", id);
let userCopy = user.getProperties('id', 'created', 'revised', 'firstname', 'lastname', 'email', 'initials', 'active', 'editor', 'admin', 'viewUsers', 'accounts');
this.set('editUser', userCopy);
this.set('password', {
password: "",
confirmation: ""
});
$('#edit-user-modal').on('show.bs.modal', function(event) { // eslint-disable-line no-unused-vars
schedule('afterRender', () => {
$("#edit-firstname").focus();
});
});
$('#edit-user-modal').modal('dispose');
$('#edit-user-modal').modal({show: true});
},
onUpdate() {
let user = this.get('editUser');
let password = this.get('password');
if (is.empty(user.firstname)) {
$("#edit-firstname").addClass("is-invalid").focus();
onAddUser() {
if (is.empty(this.get('newUser.firstname'))) {
$("#newUserFirstname").addClass('is-invalid').focus();
return;
}
if (is.empty(user.lastname)) {
$("#edit-lastname").addClass("is-invalid").focus();
$("#newUserFirstname").removeClass('is-invalid');
if (is.empty(this.get('newUser.lastname'))) {
$("#newUserLastname").addClass('is-invalid').focus();
return;
}
if (is.empty(user.email) || is.not.email(user.email)) {
$("#edit-email").addClass("is-invalid").focus();
$("#newUserLastname").removeClass('is-invalid');
if (is.empty(this.get('newUser.email')) || is.not.email(this.get('newUser.email'))) {
$("#newUserEmail").addClass('is-invalid').focus();
return;
}
$("#newUserEmail").removeClass('is-invalid');
$('#edit-user-modal').modal('hide');
$('#edit-user-modal').modal('dispose');
let user = this.get('newUser');
let cb = this.get('onSave');
cb(user);
if (is.not.empty(password.password) && is.not.empty(password.confirmation) &&
password.password === password.confirmation) {
let pw = this.get('onPassword');
pw(user, password.password);
}
},
onShowDelete(id) {
this.set('deleteUser', this.users.findBy("id", id));
this.set('showDeleteDialog', true);
},
onDelete() {
this.set('showDeleteDialog', false);
this.set('selectedUsers', []);
this.set('hasSelectedUsers', false);
let cb = this.get('onDelete');
cb(this.get('deleteUser.id'));
return true;
},
onBulkDelete() {
let su = this.get('selectedUsers');
su.forEach(userId => {
let cb = this.get('onDelete');
cb(userId);
this.get('onAddUser')(user).then(() => {
this.set('newUser', { firstname: '', lastname: '', email: '', active: true });
});
this.set('selectedUsers', []);
this.set('hasSelectedUsers', false);
this.modalClose("#add-user-modal");
},
this.modalClose('#admin-user-delete-modal');
onAddUsers() {
if (is.empty(this.get('bulkUsers'))) {
$("#bulkUsers").addClass('is-invalid').focus();
return;
}
$("#bulkUsers").removeClass('is-invalid');
this.get('onAddUsers')(this.get('bulkUsers')).then(() => {
this.set('bulkUsers', '');
});
this.modalClose("#add-user-modal");
}
}
});

View file

@ -0,0 +1,181 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import $ from 'jquery';
import { inject as service } from '@ember/service';
import { debounce } from '@ember/runloop';
import Component from '@ember/component';
import AuthProvider from '../../mixins/auth';
import ModalMixin from '../../mixins/modal';
export default Component.extend(AuthProvider, ModalMixin, {
groupSvc: service('group'),
userSvc: service('user'),
newGroup: null,
searchText: '',
showUsers: false,
showMembers: true,
users: null,
members: null,
didReceiveAttrs() {
this._super(...arguments);
this.loadGroups();
this.setDefaults();
},
loadGroups() {
this.get('groupSvc').getAll().then((groups) => {
this.set('groups', groups);
});
},
setDefaults() {
this.set('newGroup', { name: '', purpose: '' });
},
loadGroupInfo() {
let groupId = this.get('membersGroup.id');
let searchText = this.get('searchText');
this.get('groupSvc').getGroupMembers(groupId).then((members) => {
this.set('members', members);
this.get('userSvc').matchUsers(searchText).then((users) => {
users.forEach((user) => {
let m = members.findBy('userId', user.get('id'));
user.set('isMember', is.not.undefined(m));
})
if (this.get('showMembers') && members.length === 0) {
this.set('showMembers', false);
this.set('showUsers', true);
}
this.set('users', users);
});
});
},
actions: {
onOpenGroupModal() {
this.modalOpen("#add-group-modal", {"show": true}, '#new-group-name');
},
onAddGroup(e) {
e.preventDefault();
let newGroup = this.get('newGroup');
if (is.empty(newGroup.name)) {
$("#new-group-name").addClass("is-invalid").focus();
return;
}
this.get('groupSvc').add(newGroup).then(() => {
this.loadGroups();
});
this.modalClose("#add-group-modal");
this.setDefaults();
},
onShowDeleteModal(groupId) {
this.set('deleteGroup', { name: '', id: groupId });
this.modalOpen("#delete-group-modal", {"show": true}, '#delete-group-name');
},
onDeleteGroup(e) {
e.preventDefault();
let deleteGroup = this.get('deleteGroup');
let group = this.get('groups').findBy('id', deleteGroup.id);
if (is.empty(deleteGroup.name) || group.get('name') !== deleteGroup.name) {
$("#delete-group-name").addClass("is-invalid").focus();
return;
}
this.get('groupSvc').delete(deleteGroup.id).then(() => {
this.loadGroups();
});
this.modalClose("#delete-group-modal");
this.set('deleteGroup', { name: '', id: '' });
},
onShowEditModal(groupId) {
this.set('editGroup', this.get('groups').findBy('id', groupId));
this.modalOpen("#edit-group-modal", {"show": true}, '#edit-group-name');
},
onEditGroup(e) {
e.preventDefault();
let group = this.get('editGroup');
if (is.empty(group.get('name'))) {
$("#edit-group-name").addClass("is-invalid").focus();
return;
}
this.get('groupSvc').update(group).then(() => {
this.load();
});
this.modalClose("#edit-group-modal");
this.set('editGroup', null);
},
onShowMembersModal(groupId) {
this.set('membersGroup', this.get('groups').findBy('id', groupId));
this.modalOpen("#group-members-modal", {"show": true}, '#group-members-search');
this.set('members', null);
this.set('users', null);
this.set('showMembers', true);
this.set('showUsers', false);
this.loadGroupInfo();
},
onSearch() {
debounce(this, function() {
let searchText = this.get('searchText');
this.loadGroupInfo();
if (is.not.empty(searchText)) {
this.set('showMembers', false);
this.set('showUsers', true);
} else {
this.set('showMembers', true);
this.set('showUsers', false);
}
}, 250);
},
onLeaveGroup(userId) {
let groupId = this.get('membersGroup.id');
this.get('groupSvc').leave(groupId, userId).then(() => {
this.loadGroupInfo();
this.loadGroups();
});
},
onJoinGroup(userId) {
let groupId = this.get('membersGroup.id');
this.get('groupSvc').join(groupId, userId).then(() => {
this.loadGroupInfo();
this.loadGroups();
});
}
}
});

View file

@ -0,0 +1,228 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import $ from 'jquery';
import { inject as service } from '@ember/service';
import { schedule, debounce } from '@ember/runloop';
import Component from '@ember/component';
import AuthProvider from '../../mixins/auth';
import ModalMixin from '../../mixins/modal';
export default Component.extend(AuthProvider, ModalMixin, {
groupSvc: service('group'),
editUser: null,
deleteUser: null,
filter: '',
hasSelectedUsers: false,
showDeleteDialog: false,
init() {
this._super(...arguments);
this.password = {};
this.selectedUsers = [];
},
didReceiveAttrs() {
this._super(...arguments);
this.get('groupSvc').getAll().then((groups) => {
this.set('groups', groups);
});
let users = this.get('users');
users.forEach(user => {
user.set('me', user.get('id') === this.get('session.session.authenticated.user.id'));
user.set('selected', false);
});
this.set('users', users);
},
onKeywordChange: function () {
debounce(this, this.filterUsers, 350);
}.observes('filter'),
filterUsers() {
this.get('onFilter')(this.get('filter'));
},
actions: {
toggleSelect(user) {
user.set('selected', !user.get('selected'));
let su = this.get('selectedUsers');
if (user.get('selected')) {
su.push(user.get('id'));
} else {
su = _.reject(su, function(id){ return id === user.get('id') });
}
this.set('selectedUsers', su);
this.set('hasSelectedUsers', su.length > 0);
},
toggleActive(id) {
let user = this.users.findBy("id", id);
user.set('active', !user.get('active'));
let cb = this.get('onSave');
cb(user);
},
toggleEditor(id) {
let user = this.users.findBy("id", id);
user.set('editor', !user.get('editor'));
let cb = this.get('onSave');
cb(user);
},
toggleAdmin(id) {
let user = this.users.findBy("id", id);
user.set('admin', !user.get('admin'));
let cb = this.get('onSave');
cb(user);
},
toggleUsers(id) {
let user = this.users.findBy("id", id);
user.set('viewUsers', !user.get('viewUsers'));
let cb = this.get('onSave');
cb(user);
},
onShowEdit(id) {
let user = this.users.findBy("id", id);
let userCopy = user.getProperties('id', 'created', 'revised', 'firstname', 'lastname', 'email', 'initials', 'active', 'editor', 'admin', 'viewUsers', 'accounts');
this.set('editUser', userCopy);
this.set('password', {
password: "",
confirmation: ""
});
$('#edit-user-modal').on('show.bs.modal', function(event) { // eslint-disable-line no-unused-vars
schedule('afterRender', () => {
$("#edit-firstname").focus();
});
});
$('#edit-user-modal').modal('dispose');
$('#edit-user-modal').modal({show: true});
},
onUpdate() {
let user = this.get('editUser');
let password = this.get('password');
if (is.empty(user.firstname)) {
$("#edit-firstname").addClass("is-invalid").focus();
return;
}
if (is.empty(user.lastname)) {
$("#edit-lastname").addClass("is-invalid").focus();
return;
}
if (is.empty(user.email) || is.not.email(user.email)) {
$("#edit-email").addClass("is-invalid").focus();
return;
}
$('#edit-user-modal').modal('hide');
$('#edit-user-modal').modal('dispose');
let cb = this.get('onSave');
cb(user);
if (is.not.empty(password.password) && is.not.empty(password.confirmation) &&
password.password === password.confirmation) {
let pw = this.get('onPassword');
pw(user, password.password);
}
},
onShowDelete(id) {
this.set('deleteUser', this.users.findBy("id", id));
this.set('showDeleteDialog', true);
},
onDelete() {
this.set('showDeleteDialog', false);
this.set('selectedUsers', []);
this.set('hasSelectedUsers', false);
let cb = this.get('onDelete');
cb(this.get('deleteUser.id'));
return true;
},
onBulkDelete() {
let su = this.get('selectedUsers');
su.forEach(userId => {
let cb = this.get('onDelete');
cb(userId);
});
this.set('selectedUsers', []);
this.set('hasSelectedUsers', false);
this.modalClose('#admin-user-delete-modal');
},
onShowGroupsModal(userId) {
let user = this.get('users').findBy('id', userId);
this.set('selectedUser', user);
let userGroups = user.get('groups');
// mark up groups user belongs to...
let groups = this.get('groups');
groups.forEach((g) => {
let hasGroup = userGroups.findBy('roleId', g.get('id'));
g.set('isMember', is.not.undefined(hasGroup));
})
this.set('groups', groups);
this.modalOpen("#group-member-modal", {"show": true});
},
onLeaveGroup(groupId) {
let userId = this.get('selectedUser.id');
let group = this.get('groups').findBy('id', groupId);
group.set('isMember', false);
if (is.undefined(groupId) || is.undefined(userId)) {
return;
}
this.get('groupSvc').leave(groupId, userId).then(() => {
this.filterUsers();
});
},
onJoinGroup(groupId) {
let userId = this.get('selectedUser.id');
let group = this.get('groups').findBy('id', groupId);
group.set('isMember', true);
if (is.undefined(groupId) || is.undefined(userId)) {
return;
}
this.get('groupSvc').join(groupId, userId).then(() => {
this.filterUsers();
});
}
}
});

View file

@ -1,58 +0,0 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import $ from 'jquery';
import { empty, and } from '@ember/object/computed';
import Component from '@ember/component';
import { isEmpty } from '@ember/utils';
import { get, set } from '@ember/object';
import AuthProvider from '../../mixins/auth';
export default Component.extend(AuthProvider, {
firstnameEmpty: empty('newUser.firstname'),
lastnameEmpty: empty('newUser.lastname'),
emailEmpty: empty('newUser.email'),
hasFirstnameEmptyError: and('firstnameEmpty', 'firstnameError'),
hasLastnameEmptyError: and('lastnameEmpty', 'lastnameError'),
hasEmailEmptyError: and('emailEmpty', 'emailError'),
init() {
this._super(...arguments);
this.newUser = { firstname: "", lastname: "", email: "", active: true };
},
actions: {
add() {
if (isEmpty(this.get('newUser.firstname'))) {
set(this, 'firstnameError', true);
return $("#newUserFirstname").focus();
}
if (isEmpty(this.get('newUser.lastname'))) {
set(this, 'lastnameError', true);
return $("#newUserLastname").focus();
}
if (isEmpty(this.get('newUser.email')) || is.not.email(this.get('newUser.email'))) {
set(this, 'emailError', true);
return $("#newUserEmail").focus();
}
let user = get(this, 'newUser');
get(this, 'add')(user).then(() => {
this.set('newUser', { firstname: "", lastname: "", email: "", active: true });
set(this, 'firstnameError', false);
set(this, 'lastnameError', false);
set(this, 'emailError', false);
$("#newUserFirstname").focus();
});
}
}
});

View file

@ -10,14 +10,16 @@
// https://documize.com
import $ from 'jquery';
import Component from '@ember/component';
import { A } from "@ember/array"
import { inject as service } from '@ember/service';
import TooltipMixin from '../../mixins/tooltip';
import ModalMixin from '../../mixins/modal';
import Component from '@ember/component';
export default Component.extend(ModalMixin, TooltipMixin, {
userService: service('user'),
categoryService: service('category'),
spaceSvc: service('folder'),
groupSvc: service('group'),
categorySvc: service('category'),
appMeta: service(),
store: service(),
newCategory: '',
@ -42,43 +44,42 @@ export default Component.extend(ModalMixin, TooltipMixin, {
load() {
// get categories
this.get('categoryService').getAll(this.get('folder.id')).then((c) => {
this.get('categorySvc').getAll(this.get('folder.id')).then((c) => {
this.set('category', c);
// get summary of documents and users for each category in space
this.get('categoryService').getSummary(this.get('folder.id')).then((s) => {
this.get('categorySvc').getSummary(this.get('folder.id')).then((s) => {
c.forEach((cat) => {
let docs = _.findWhere(s, {categoryId: cat.get('id'), type: 'documents'});
let docCount = is.not.undefined(docs) ? docs.count : 0;
let users = _.findWhere(s, {categoryId: cat.get('id'), type: 'users'});
let userCount = is.not.undefined(users) ? users.count : 0;
let docs = _.where(s, {categoryId: cat.get('id'), type: 'documents'});
let docCount = 0;
docs.forEach((d) => { docCount = docCount + d.count });
let users = _.where(s, {categoryId: cat.get('id'), type: 'users'});
let userCount = 0;
users.forEach((u) => { userCount = userCount + u.count });
cat.set('documents', docCount);
cat.set('users', userCount);
});
});
// get users that this space admin user can see
this.get('userService').getSpaceUsers(this.get('folder.id')).then((users) => {
// set up Everyone user
let u = {
orgId: this.get('folder.orgId'),
folderId: this.get('folder.id'),
userId: '',
firstname: 'Everyone',
lastname: '',
};
let data = this.get('store').normalize('user', u)
users.pushObject(this.get('store').push(data));
users = users.sortBy('firstname', 'lastname');
this.set('users', users);
});
});
},
permissionRecord(who, whoId, name) {
let raw = {
id: whoId,
orgId: this.get('folder.orgId'),
categoryId: this.get('currentCategory.id'),
whoId: whoId,
who: who,
name: name,
categoryView: false,
};
let rec = this.get('store').normalize('category-permission', raw);
return this.get('store').push(rec);
},
setEdit(id, val) {
let cats = this.get('category');
let cat = cats.findBy('id', id);
@ -109,7 +110,7 @@ export default Component.extend(ModalMixin, TooltipMixin, {
folderId: this.get('folder.id')
};
this.get('categoryService').add(c).then(() => {
this.get('categorySvc').add(c).then(() => {
this.load();
});
},
@ -124,7 +125,7 @@ export default Component.extend(ModalMixin, TooltipMixin, {
onDelete() {
this.modalClose('#category-delete-modal');
this.get('categoryService').delete(this.get('deleteId')).then(() => {
this.get('categorySvc').delete(this.get('deleteId')).then(() => {
this.load();
});
},
@ -150,7 +151,7 @@ export default Component.extend(ModalMixin, TooltipMixin, {
cat = this.setEdit(id, false);
$('#edit-category-' + cat.get('id')).removeClass('is-invalid');
this.get('categoryService').save(cat).then(() => {
this.get('categorySvc').save(cat).then(() => {
this.load();
});
@ -160,45 +161,46 @@ export default Component.extend(ModalMixin, TooltipMixin, {
onShowAccessPicker(catId) {
this.set('showCategoryAccess', true);
let users = this.get('users');
let categoryPermissions = A([]);
let category = this.get('category').findBy('id', catId);
this.get('categoryService').getPermissions(category.get('id')).then((viewers) => {
// mark those users as selected that have already been given permission
// to see the current category;
users.forEach((user) => {
let userId = user.get('id');
let selected = viewers.isAny('whoId', userId);
user.set('selected', selected);
this.set('currentCategory', category);
this.set('categoryPermissions', categoryPermissions);
// get space permissions
this.get('spaceSvc').getPermissions(this.get('folder.id')).then((spacePermissions) => {
spacePermissions.forEach((sp) => {
let cp = this.permissionRecord(sp.get('who'), sp.get('whoId'), sp.get('name'));
cp.set('selected', false);
categoryPermissions.pushObject(cp);
});
this.set('categoryUsers', users);
this.set('currentCategory', category);
this.get('categorySvc').getPermissions(category.get('id')).then((perms) => {
// mark those users as selected that have permission to see the current category
perms.forEach((perm) => {
let c = categoryPermissions.findBy('whoId', perm.get('whoId'));
if (is.not.undefined(c)) {
c.set('selected', true);
}
});
this.set('categoryPermissions', categoryPermissions.sortBy('who', 'name'));
});
});
},
onToggle(item) {
item.set('selected', !item.get('selected'));
},
onGrantAccess() {
this.set('showCategoryAccess', false);
let folder = this.get('folder');
let category = this.get('currentCategory');
let users = this.get('categoryUsers').filterBy('selected', true);
let viewers = [];
let perms = this.get('categoryPermissions').filterBy('selected', true);
users.forEach((user) => {
let userId = user.get('id');
let v = {
orgId: this.get('folder.orgId'),
folderId: this.get('folder.id'),
categoryId: category.get('id'),
userId: userId
};
viewers.push(v);
});
this.get('categoryService').setViewers(folder.get('id'), category.get('id'), viewers).then(() => {
this.get('categorySvc').setViewers(folder.get('id'), category.get('id'), perms).then(() => {
this.load();
});
}

View file

@ -9,93 +9,128 @@
//
// https://documize.com
import { setProperties } from '@ember/object';
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { A } from "@ember/array"
import { debounce } from '@ember/runloop';
import ModalMixin from '../../mixins/modal';
import Component from '@ember/component';
export default Component.extend(ModalMixin, {
folderService: service('folder'),
userService: service('user'),
appMeta: service(),
groupSvc: service('group'),
spaceSvc: service('folder'),
userSvc: service('user'),
appMeta: service(),
store: service(),
spacePermissions: null,
users: null,
searchText: '',
didReceiveAttrs() {
this.get('userService').getSpaceUsers(this.get('folder.id')).then((users) => {
this.set('users', users);
let spacePermissions = A([]);
let constants = this.get('constants');
// set up users
let folderPermissions = [];
// get groups
this.get('groupSvc').getAll().then((groups) => {
this.set('groups', groups);
users.forEach((user) => {
let u = {
orgId: this.get('folder.orgId'),
folderId: this.get('folder.id'),
userId: user.get('id'),
fullname: user.get('fullname'),
spaceView: false,
spaceManage: false,
spaceOwner: false,
documentAdd: false,
documentEdit: false,
documentDelete: false,
documentMove: false,
documentCopy: false,
documentTemplate: false,
documentApprove: false,
};
let data = this.get('store').normalize('space-permission', u)
folderPermissions.pushObject(this.get('store').push(data));
groups.forEach((g) => {
let pr = this.permissionRecord(constants.WhoType.Group, g.get('id'), g.get('name'));
pr.set('members', g.get('members'));
spacePermissions.pushObject(pr);
});
// set up Everyone user
let u = {
orgId: this.get('folder.orgId'),
folderId: this.get('folder.id'),
userId: '0',
fullname: ' Everyone',
spaceView: false,
spaceManage: false,
spaceOwner: false,
documentAdd: false,
documentEdit: false,
documentDelete: false,
documentMove: false,
documentCopy: false,
documentTemplate: false,
documentApprove: false,
};
let hasEveryoneId = false;
let data = this.get('store').normalize('space-permission', u)
folderPermissions.pushObject(this.get('store').push(data));
this.get('folderService').getPermissions(this.get('folder.id')).then((permissions) => {
permissions.forEach((permission, index) => { // eslint-disable-line no-unused-vars
let record = folderPermissions.findBy('userId', permission.get('userId'));
if (is.not.undefined(record)) {
record = setProperties(record, permission);
// get space permissions
this.get('spaceSvc').getPermissions(this.get('folder.id')).then((permissions) => {
permissions.forEach((perm, index) => { // eslint-disable-line no-unused-vars
// is this permission for group or user?
if (perm.get('who') === constants.WhoType.Group) {
// group permission
spacePermissions.forEach((sp) => {
if (sp.get('whoId') == perm.get('whoId')) {
sp.setProperties(perm);
}
});
} else {
// user permission
if (perm.get('whoId') === constants.EveryoneUserId) {
perm.set('name', ' ' + perm.get('name'));
hasEveryoneId = true;
}
spacePermissions.pushObject(perm);
}
});
this.set('permissions', folderPermissions.sortBy('fullname'));
// always show everyone
if (!hasEveryoneId) {
let pr = this.permissionRecord(constants.WhoType.User, constants.EveryoneUserId, ' ' + constants.EveryoneUserName);
spacePermissions.pushObject(pr);
}
this.set('spacePermissions', spacePermissions.sortBy('who', 'name'));
});
});
this.set('searchText', '');
},
permissionRecord(who, whoId, name) {
let raw = {
id: whoId,
orgId: this.get('folder.orgId'),
folderId: this.get('folder.id'),
whoId: whoId,
who: who,
name: name,
spaceView: false,
spaceManage: false,
spaceOwner: false,
documentAdd: false,
documentEdit: false,
documentDelete: false,
documentMove: false,
documentCopy: false,
documentTemplate: false,
documentApprove: false,
};
let rec = this.get('store').normalize('space-permission', raw);
return this.get('store').push(rec);
},
getDefaultInvitationMessage() {
return "Hey there, I am sharing the " + this.get('folder.name') + " space (in " + this.get("appMeta.title") + ") with you so we can both collaborate on documents.";
},
matchUsers(s) {
let spacePermissions = this.get('spacePermissions');
let filteredUsers = A([]);
this.get('userSvc').matchUsers(s).then((users) => {
users.forEach((user) => {
let exists = spacePermissions.findBy('whoId', user.get('id'));
if (is.undefined(exists)) {
filteredUsers.pushObject(user);
}
});
this.set('filteredUsers', filteredUsers);
});
},
actions: {
setPermissions() {
let message = this.getDefaultInvitationMessage();
let permissions = this.get('permissions');
let permissions = this.get('spacePermissions');
let folder = this.get('folder');
let payload = { Message: message, Permissions: permissions };
let constants = this.get('constants');
let hasEveryone = _.find(permissions, function (permission) {
return permission.get('userId') === "0" &&
let hasEveryone = _.find(permissions, (permission) => {
return permission.get('whoId') === constants.EveryoneUserId &&
(permission.get('spaceView') || permission.get('documentAdd') || permission.get('documentEdit') || permission.get('documentDelete') ||
permission.get('documentMove') || permission.get('documentCopy') || permission.get('documentTemplate') || permission.get('documentApprove'));
});
@ -103,7 +138,7 @@ export default Component.extend(ModalMixin, {
// see if more than oen user is granted access to space (excluding everyone)
let roleCount = 0;
permissions.forEach((permission) => {
if (permission.get('userId') !== "0" &&
if (permission.get('whoId') !== constants.EveryoneUserId &&
(permission.get('spaceView') || permission.get('documentAdd') || permission.get('documentEdit') || permission.get('documentDelete') ||
permission.get('documentMove') || permission.get('documentCopy') || permission.get('documentTemplate') || permission.get('documentApprove'))) {
roleCount += 1;
@ -120,9 +155,34 @@ export default Component.extend(ModalMixin, {
}
}
this.get('folderService').savePermissions(folder.get('id'), payload).then(() => {
this.get('spaceSvc').savePermissions(folder.get('id'), payload).then(() => {
this.modalClose('#space-permission-modal');
});
}
},
onSearch() {
debounce(this, function() {
let searchText = this.get('searchText').trim();
if (searchText.length === 0) {
this.set('filteredUsers', A([]));
return;
}
this.matchUsers(searchText);
}, 250);
},
onAdd(user) {
let spacePermissions = this.get('spacePermissions');
let constants = this.get('constants');
let exists = spacePermissions.findBy('whoId', user.get('id'));
if (is.undefined(exists)) {
spacePermissions.pushObject(this.permissionRecord(constants.WhoType.User, user.get('id'), user.get('fullname')));
this.set('spacePermissions', spacePermissions);
// this.set('spacePermissions', spacePermissions.sortBy('who', 'name'));
}
},
}
});

View file

@ -10,7 +10,6 @@
// https://documize.com
import $ from 'jquery';
import Component from '@ember/component';
import { computed } from '@ember/object';
import { schedule } from '@ember/runloop';
import { inject as service } from '@ember/service';
@ -18,6 +17,7 @@ import TooltipMixin from '../../mixins/tooltip';
import ModalMixin from '../../mixins/modal';
import AuthMixin from '../../mixins/auth';
import stringUtil from '../../utils/string';
import Component from '@ember/component';
export default Component.extend(ModalMixin, TooltipMixin, AuthMixin, {
spaceService: service('folder'),

View file

@ -11,6 +11,9 @@
import EmberObject from "@ember/object";
// access like so:
// let constants = this.get('constants');
let constants = EmberObject.extend({
// Document
ProtectionType: { // eslint-disable-line ember/avoid-leaking-state-in-ember-objects
@ -48,7 +51,16 @@ let constants = EmberObject.extend({
PageType: { // eslint-disable-line ember/avoid-leaking-state-in-ember-objects
Tab: 'tab',
Section: 'section'
}
},
// Who a permission record relates to
WhoType: { // eslint-disable-line ember/avoid-leaking-state-in-ember-objects
User: 'user',
Group: 'role'
},
EveryoneUserId: "0",
EveryoneUserName: "Everyone"
});
export default { constants }

View file

@ -0,0 +1,23 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
export default Model.extend({
orgId: attr('string'),
categoryId: attr('string'),
whoId: attr('string'),
who: attr('string'),
categoryView: attr('boolean'),
name: attr('string'), // read-only
selected: attr('boolean') // client-side only
});

View file

@ -15,11 +15,11 @@ import attr from 'ember-data/attr';
export default Model.extend({
orgId: attr('string'),
documentId: attr('string'),
userId: attr('string'),
fullname: attr('string'), // client-side usage only, not from API
documentEdit: attr('boolean'), // space level setting
documentApprove: attr('boolean'), // space level setting
documentRoleEdit: attr('boolean'), // document level setting
documentRoleApprove: attr('boolean') // document level setting
whoId: attr('string'),
who: attr('string'),
name: attr('string'),
documentEdit: attr('boolean'),
documentApprove: attr('boolean'),
documentRoleEdit: attr('boolean'),
documentRoleApprove: attr('boolean')
});

View file

@ -0,0 +1,27 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { computed } from '@ember/object';
export default Model.extend({
orgId: attr('string'),
roleId: attr('string'),
userId: attr('string'),
// for UI only
firstname: attr('string'),
lastname: attr('string'),
fullname: computed('firstname', 'lastname', function () {
return `${this.get('firstname')} ${this.get('lastname')}`;
})
});

24
gui/app/models/group.js Normal file
View file

@ -0,0 +1,24 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
export default Model.extend({
orgId: attr('string'),
name: attr('string'),
purpose: attr('string'),
created: attr(),
revised: attr(),
// for UI
members: attr('number', { defaultValue: 0 })
});

View file

@ -11,14 +11,12 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
// import { belongsTo, hasMany } from 'ember-data/relationships';
export default Model.extend({
orgId: attr('string'),
folderId: attr('string'),
userId: attr('string'),
fullname: attr('string'), // client-side usage only, not from API
whoId: attr('string'),
who: attr('string'),
spaceView: attr('boolean'),
spaceManage: attr('boolean'),
spaceOwner: attr('boolean'),
@ -28,5 +26,7 @@ export default Model.extend({
documentMove: attr('boolean'),
documentCopy: attr('boolean'),
documentTemplate: attr('boolean'),
documentApprove: attr('boolean')
documentApprove: attr('boolean'),
name: attr('string'), // read-only
members: attr('number') // read-only
});

View file

@ -12,7 +12,6 @@
import { computed } from '@ember/object';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
// import { belongsTo, hasMany } from 'ember-data/relationships';
export default Model.extend({
firstname: attr('string'),
@ -25,6 +24,7 @@ export default Model.extend({
viewUsers: attr('boolean', { defaultValue: false }),
global: attr('boolean', { defaultValue: false }),
accounts: attr(),
groups: attr(),
created: attr(),
revised: attr(),

View file

@ -0,0 +1,17 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import Controller from '@ember/controller';
export default Controller.extend({
actions: {
}
});

View file

@ -0,0 +1,25 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import Route from '@ember/routing/route';
import AuthenticatedRouteMixin from 'ember-simple-auth/mixins/authenticated-route-mixin';
export default Route.extend(AuthenticatedRouteMixin, {
beforeModel () {
if (!this.session.isAdmin) {
this.transitionTo('auth.login');
}
},
activate() {
this.get('browser').setTitle('Groups');
}
});

View file

@ -0,0 +1 @@
{{customize/user-groups}}

View file

@ -18,7 +18,8 @@ export default Controller.extend({
actions: {
saveSMTP() {
if(this.get('session.isGlobalAdmin')) {
return this.get('global').saveSMTPConfig(this.model.smtp).then(() => {
return this.get('global').saveSMTPConfig(this.model.smtp).then((response) => {
return response;
});
}
}

View file

@ -14,6 +14,7 @@
<ul class="tabnav-control">
{{#link-to 'customize.general' activeClass='selected' class="tab" tagName="li"}}General{{/link-to}}
{{#link-to 'customize.folders' activeClass='selected' class="tab" tagName="li"}}Spaces{{/link-to}}
{{#link-to 'customize.groups' activeClass='selected' class="tab" tagName="li"}}Groups{{/link-to}}
{{#link-to 'customize.users' activeClass='selected' class="tab" tagName="li"}}Users{{/link-to}}
{{#if session.isGlobalAdmin}}
{{#link-to 'customize.smtp' activeClass='selected' class="tab" tagName="li"}}SMTP{{/link-to}}

View file

@ -9,54 +9,49 @@
//
// https://documize.com
import { set } from '@ember/object';
import { inject as service } from '@ember/service';
import Controller from '@ember/controller';
export default Controller.extend({
userService: service('user'),
init() {
this._super(...arguments);
this.newUser = { firstname: "", lastname: "", email: "", active: true };
loadUsers(filter) {
this.get('userService').getComplete(filter).then((users) => {
this.set('model', users);
});
},
actions: {
add(user) {
set(this, 'newUser', user);
return this.get('userService')
.add(this.get('newUser'))
.then((user) => {
this.get('model').pushObject(user);
})
.catch(function (error) {
let msg = error.status === 409 ? 'Unable to add duplicate user' : 'Unable to add user';
this.showNotification(msg);
});
onAddUser(user) {
return this.get('userService').add(user).then((user) => {
this.get('model').pushObject(user);
});
},
onAddUsers(list) {
return this.get('userService').addBulk(list).then(() => {
this.loadUsers('');
});
},
onDelete(userId) {
let self = this;
this.get('userService').remove(userId).then(function () {
self.get('userService').getComplete().then(function (users) {
self.set('model', users);
});
this.get('userService').remove(userId).then( () => {
this.loadUsers('');
});
},
onSave(user) {
let self = this;
this.get('userService').save(user).then(function () {
self.get('userService').getComplete().then(function (users) {
self.set('model', users);
});
this.get('userService').save(user).then(() => {
this.loadUsers('');
});
},
onPassword(user, password) {
this.get('userService').updatePassword(user.id, password);
},
onFilter(filter) {
this.loadUsers(filter);
}
}
});

View file

@ -30,12 +30,12 @@ export default Route.extend(AuthenticatedRouteMixin, {
return new EmberPromise((resolve) => {
if (this.get('appMeta.authProvider') == constants.AuthProvider.Keycloak) {
this.get('global').syncExternalUsers().then(() => {
this.get('userService').getComplete().then((users) =>{
this.get('userService').getComplete('').then((users) =>{
resolve(users);
});
});
} else {
this.get('userService').getComplete().then((users) => {
this.get('userService').getComplete('').then((users) => {
resolve(users);
});
}

View file

@ -1,12 +1,9 @@
<div class="row">
<div class="col">
<div class="view-customize">
<h1 class="admin-heading">Users</h1>
<h2 class="sub-heading">Set basic information, passwords and permissions for {{model.length}} users</h2>
</div>
</div>
</div>
{{customize/user-admin users=model
onAddUser=(action 'onAddUser')
onAddUsers=(action 'onAddUsers')}}
{{customize/user-settings add=(action 'add')}}
{{customize/user-admin users=model onDelete=(action "onDelete") onSave=(action "onSave") onPassword=(action "onPassword")}}
{{customize/user-list users=model
onFilter=(action "onFilter")
onDelete=(action "onDelete")
onSave=(action "onSave")
onPassword=(action "onPassword")}}

View file

@ -26,6 +26,9 @@ export default Controller.extend(TooltipMixin, {
actions: {
onTabChange(tab) {
this.set('tab', tab);
if (tab === 'content') {
this.send('refresh');
}
},
onShowPage(pageId) {

View file

@ -16,7 +16,8 @@ export default Route.extend({
beforeModel() {
let pwd = document.head.querySelector("[property=dbhash]").content;
if (pwd.length === 0 || pwd === "{{.DBhash}}") {
this.transitionTo('auth.login'); // don't allow access to this page if we are not in setup mode, kick them out altogether
// don't allow access to this page if we are not in setup mode
this.transitionTo('auth.login');
}
},

View file

@ -50,6 +50,9 @@ export default Router.map(function () {
this.route('general', {
path: 'general'
});
this.route('groups', {
path: 'groups'
});
this.route('users', {
path: 'users'
});

View file

@ -4,7 +4,7 @@ export default ApplicationSerializer.extend({
normalize(modelClass, resourceHash) {
return {
data: {
id: resourceHash.userId ? resourceHash.userId : 0,
id: resourceHash.whoId ? resourceHash.whoId : 0,
type: modelClass.modelName,
attributes: resourceHash
}

View file

@ -0,0 +1,13 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
normalize(modelClass, resourceHash) {
return {
data: {
id: resourceHash.whoId ? resourceHash.whoId : 0,
type: modelClass.modelName,
attributes: resourceHash
}
};
}
});

View file

@ -4,7 +4,7 @@ export default ApplicationSerializer.extend({
normalize(modelClass, resourceHash) {
return {
data: {
id: resourceHash.userId ? resourceHash.userId : 0,
id: resourceHash.whoId ? resourceHash.whoId : 0,
type: modelClass.modelName,
attributes: resourceHash
}

View file

@ -87,7 +87,15 @@ export default BaseService.extend({
return this.get('ajax').request(`category/${categoryId}/permission`, {
method: 'GET'
}).then((response) => {
return response;
// return response;
let data = [];
data = response.map((obj) => {
let data = this.get('store').normalize('category-permission', obj);
return this.get('store').push(data);
});
return data;
});
},

View file

@ -343,7 +343,7 @@ export default Service.extend({
perms = this.get('store').push(perms);
this.get('folderService').set('permissions', perms);
let roles = this.get('store').normalize('document-role', response.roles);
let roles = this.get('store').normalize('document-permission', response.roles);
roles = this.get('store').push(roles);
let folders = response.folders.map((obj) => {

View file

@ -20,6 +20,7 @@ export default BaseService.extend({
localStorage: service(),
store: service(),
currentFolder: null,
permissions: null,
init() {
this._super(...arguments);

98
gui/app/services/group.js Normal file
View file

@ -0,0 +1,98 @@
// Copyright 2016 Documize Inc. <legal@documize.com>. All rights reserved.
//
// This software (Documize Community Edition) is licensed under
// GNU AGPL v3 http://www.gnu.org/licenses/agpl-3.0.en.html
//
// You can operate outside the AGPL restrictions by purchasing
// Documize Enterprise Edition and obtaining a commercial license
// by contacting <sales@documize.com>.
//
// https://documize.com
import { inject as service } from '@ember/service';
import BaseService from '../services/base';
export default BaseService.extend({
sessionService: service('session'),
ajax: service(),
localStorage: service(),
store: service(),
// Add user group.
add(payload) {
return this.get('ajax').post(`group`, {
contentType: 'json',
data: JSON.stringify(payload)
}).then((group) => {
let data = this.get('store').normalize('group', group);
return this.get('store').push(data);
});
},
// Returns all groups for org.
getAll() {
return this.get('ajax').request(`group`, {
method: 'GET'
}).then((response) => {
let data = [];
data = response.map((obj) => {
let data = this.get('store').normalize('group', obj);
return this.get('store').push(data);
});
return data;
});
},
// Updates an existing group.
update(group) {
let id = group.get('id');
return this.get('ajax').request(`group/${id}`, {
method: 'PUT',
contentType: 'json',
data: JSON.stringify(group)
}).then((group) => {
let data = this.get('store').normalize('group', group);
return this.get('store').push(data);
});
},
// Delete removes group and associated user membership.
delete(groupId) {
return this.get('ajax').request(`group/${groupId}`, {
method: 'DELETE'
});
},
// Returns users associated with given group
getGroupMembers(groupId) {
return this.get('ajax').request(`group/${groupId}/members`, {
method: 'GET'
}).then((response) => {
let data = [];
data = response.map((obj) => {
let data = this.get('store').normalize('group-member', obj);
return this.get('store').push(data);
});
return data;
});
},
// join adds user to group.
join(groupId, userId) {
return this.get('ajax').request(`group/${groupId}/join/${userId}`, {
method: 'POST'
});
},
// leave removes user from group.
leave(groupId, userId) {
return this.get('ajax').request(`group/${groupId}/leave/${userId}`, {
method: 'DELETE'
});
},
});

View file

@ -31,6 +31,16 @@ export default Service.extend({
});
},
// Adds comma-delim list of users (firstname, lastname, email).
addBulk(list) {
return this.get('ajax').request(`users/import`, {
type: 'POST',
data: list,
contentType: 'text'
}).then(() => {
});
},
// Returns user model for specified user id.
getUser(userId) {
let url = `users/${userId}`;
@ -54,8 +64,13 @@ export default Service.extend({
},
// Returns all active and inactive users for organization.
getComplete() {
return this.get('ajax').request(`users?active=0`).then((response) => {
// Only available for admins and limits results to max. 100 users.
// Takes filter for user search criteria.
getComplete(filter) {
filter = filter.trim();
if (filter.length > 0) filter = encodeURIComponent(filter);
return this.get('ajax').request(`users?active=0&filter=${filter}`).then((response) => {
return response.map((obj) => {
let data = this.get('store').normalize('user', obj);
return this.get('store').push(data);
@ -145,5 +160,24 @@ export default Service.extend({
method: "POST",
data: password
});
}
},
// matchUsers on firstname, lastname, email
matchUsers(text) {
return this.get('ajax').request('users/match', {
method: 'POST',
dataType: 'json',
contentType: 'text',
data: text
}).then((response) => {
let data = [];
data = response.map((obj) => {
let data = this.get('store').normalize('user', obj);
return this.get('store').push(data);
});
return data;
});
}
});

View file

@ -106,3 +106,7 @@ $link-hover-decoration: none;
.modal-80 {
max-width: 80% !important;
}
body.modal-open {
padding-right: 0 !important;
}

View file

@ -72,6 +72,9 @@ $color-stroke: #e1e1e1;
.color-gray {
color: $color-gray !important;
}
.color-gold {
color: $color-goldy !important;
}
.background-color-white {
background-color: $color-white !important;

View file

@ -11,16 +11,37 @@
}
.user-table {
.name {
font-size: 1rem;
color: $color-off-black;
margin: 0 0 0 30px;
tbody tr td, thead tr th {
border-top: none !important;
border-bottom: none !important;
}
.email {
font-size: 0.9rem;
.name {
font-size: 1.2rem;
font-weight: bold;
color: $color-link;
margin: 0 0 0 10px;
display: inline-block;
cursor: pointer;
> .email {
font-size: 0.9rem;
color: $color-off-black;
margin: 0;
display: inline-block;
font-weight: normal;
}
}
.groups {
cursor: pointer;
margin: 5px 0 0 10px;
font-size: 1rem;
color: $color-gray;
margin: 0 0 0 30px;
&:hover {
color: $color-link;
}
}
.inactive-user
@ -55,4 +76,64 @@
color: $color-primary;
}
}
> .groups-list {
padding: 0;
margin: 0;
> .group {
margin: 15px 0;
.name {
font-size: 1.2rem;
color: $color-off-black;
> .purpose {
font-size: 1rem;
color: $color-gray;
display: inline-block;
}
}
}
}
// used for group admin
> .group-users-members {
> .item {
margin: 10px 0;
> .fullname {
color: $color-primary;
font-size: 1.2rem;
}
}
}
// used for user admin
> .group-membership {
> .item {
margin: 10px 0;
> .group-name {
color: $color-primary;
font-size: 1.2rem;
> .group-purpose {
font-size: 0.9rem;
}
}
}
}
> .smtp-failure {
font-size: 1.2rem;
font-weight: bold;
color: $color-red;
}
> .smtp-success {
font-size: 1.2rem;
font-weight: bold;
color: $color-green;
}
}

View file

@ -1,6 +1,7 @@
.wysiwyg {
font-size: 17px;
line-height: 30px;
line-height: 25px;
// line-height: 30px;
color: $color-off-black;
table {

View file

@ -10,40 +10,82 @@
<div class="view-customize">
<form class="mt-5">
<div class="form-group row">
<label for="smtp-host" class="col-sm-2 col-form-label">Host</label>
<div class="col-sm-10">
<label for="smtp-host" class="col-sm-4 col-form-label">Host</label>
<div class="col-sm-8">
{{focus-input id="smtp-host" type="text" value=model.smtp.host class=(if SMTPHostEmptyError 'form-control is-invalid' 'form-control')}}
<small class="form-text text-muted">e.g. my.host.com</small>
</div>
</div>
<div class="form-group row">
<label for="smtp-port" class="col-sm-2 col-form-label">Port</label>
<div class="col-sm-10">
<label for="smtp-port" class="col-sm-4 col-form-label">Port</label>
<div class="col-sm-8">
{{input id="smtp-port" type="text" value=model.smtp.port class=(if SMTPPortEmptyError 'form-control is-invalid' 'form-control')}}
<small class="form-text text-muted">e.g. 587</small>
</div>
</div>
<div class="form-group row">
<label for="smtp-sender" class="col-sm-2 col-form-label">Sender</label>
<div class="col-sm-10">
{{input id="smtp-sender" type="text" value=model.smtp.sender class=(if SMTPSenderEmptyError 'form-control is-invalid' 'form-control')}}
<small class="form-text text-muted">e.g. user@some-domain.com</small>
</div>
</div>
<div class="form-group row">
<label for="smtp-userid" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">
{{input id="smtp-userid" type="text" value=model.smtp.userid class=(if SMTPUserIdEmptyError 'form-control is-invalid' 'form-control')}}
<label for="smtp-userid" class="col-sm-4 col-form-label">Username</label>
<div class="col-sm-8">
{{input id="smtp-userid" type="text" value=model.smtp.userid class='form-control'}}
<small class="form-text text-muted">e.g. Login username for SMTP server</small>
</div>
</div>
<div class="form-group row">
<label for="smtp-password" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-10">
{{input id="smtp-password" type="password" value=model.smtp.password class=(if SMTPPasswordEmptyError 'form-control is-invalid' 'form-control')}}
<label for="smtp-password" class="col-sm-4 col-form-label">Password</label>
<div class="col-sm-8">
{{input id="smtp-password" type="password" value=model.smtp.password class='form-control'}}
<small class="form-text text-muted">e.g. Login password for SMTP server</small>
</div>
</div>
<div class="btn btn-success mt-4" {{action 'saveSMTP'}}>Save</div>
<div class="form-group row">
<label for="smtp-sender" class="col-sm-4 col-form-label">Sender Email</label>
<div class="col-sm-8">
{{input id="smtp-sender" type="email" value=model.smtp.sender class=(if SMTPSenderEmptyError 'form-control is-invalid' 'form-control')}}
<small class="form-text text-muted">e.g. user@some-domain.com</small>
</div>
</div>
<div class="form-group row">
<label for="smtp-senderName" class="col-sm-4 col-form-label">Sender Name</label>
<div class="col-sm-8">
{{input id="smtp-senderName" type="text" value=model.smtp.senderName class=(if senderNameError 'form-control is-invalid' 'form-control')}}
<small class="form-text text-muted">e.g. Documize</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label">Anonymous</label>
<div class="col-sm-8">
<div class="form-check">
{{input id="smtp-anonymous" type="checkbox" checked=model.smtp.anonymous class='form-check-input'}}
<label class="form-check-label" for="smtp-anonymous">Anonymous authentication, ignore Username and Password fields</label>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label">Base64</label>
<div class="col-sm-8">
<div class="form-check">
{{input id="smtp-base64creds" type="checkbox" checked=model.smtp.base64creds class='form-check-input'}}
<label class="form-check-label" for="smtp-base64creds">Base64 encode Username and Password fields</label>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-4 col-form-label">SSL</label>
<div class="col-sm-8">
<div class="form-check">
{{input id="smtp-usessl" type="checkbox" checked=model.smtp.usessl class='form-check-input'}}
<label class="form-check-label" for="smtp-usessl">Use SSL</label>
</div>
</div>
</div>
<div class="btn btn-success mt-4" {{action 'saveSMTP'}}>{{buttonText}}</div>
</form>
{{#if testSMTP.success}}
<p class="smtp-success my-3">{{testSMTP.message}}</p>
{{else}}
<p class="smtp-failure my-3">{{testSMTP.message}}</p>
{{/if}}
</div>

View file

@ -1,153 +1,56 @@
<div class="view-customize mb-5">
<h3>Users</h3>
<table class="table table-hover table-responsive user-table">
<thead>
<tr>
<th>{{input type="text" class="form-control" placeholder="filter users" value=filter}}</th>
<th class="no-width">Add Space</th>
<th class="no-width">View Users</th>
<th class="no-width">Admin</th>
<th class="no-width">Active</th>
<th class="no-width">
{{#if hasSelectedUsers}}
<button id="bulk-delete-users" type="button" class="btn btn-danger btn-sm" data-toggle="modal" data-target="#admin-user-delete-modal" data-backdrop="static">Delete</button>
<div class="row">
<div class="col">
<div class="view-customize">
<h1 class="admin-heading">Users</h1>
<h2 class="sub-heading">Set basic information, passwords and permissions for {{users.length}} users</h2>
<div id="admin-user-delete-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">Delete Users</div>
<div class="modal-body">
<p>Are you sure you want to delete selected users?</p>
{{#if isAuthProviderDocumize}}
<div class="btn btn-success mt-3 mb-3" {{action 'onOpenUserModal'}}>Add user</div>
<div id="add-user-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">Add User</div>
<div class="modal-body">
<form onsubmit={{action 'onAddUser'}}>
<div class="form-row">
<div class="col">
<label for="newUserFirstname">Firstname</label>
{{input id="newUserFirstname" type="text" class="form-control" placeholder="Firstname" value=newUser.firstname}}
</div>
<div class="col">
<label for="newUserLastname">Lastname</label>
{{input id="newUserLastname" type="text" class="form-control" placeholder="Lastname" value=newUser.lastname}}
</div>
<div class="col">
<label for="newUserEmail">Lastname</label>
{{input id="newUserEmail" type="email" class="form-control" placeholder="Email" value=newUser.email}}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" onclick={{action 'onBulkDelete'}}>Delete</button>
<div class="form-row">
<div class="col mt-3 text-right">
<button type="submit" class="btn btn-success" {{action 'onAddUser'}}>Add user</button>
</div>
</div>
</div>
</form>
<form onsubmit={{action 'onAddUser'}}>
<div class="form-group">
<label for="edit-group-desc">Bulk create users</label>
{{textarea id="bulkUsers" value=bulkUsers class="form-control" rows="5" placeholder="firstname, lastname, email"}}
<small class="form-text text-muted">Comma-delimited list: firstname, lastname, email</small>
</div>
<div class="text-right">
<button type="submit" class="btn btn-success" {{action 'onAddUsers'}}>Add users</button>
</div>
</form>
</div>
<div class="modal-footer mt-4">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Close</button>
</div>
</div>
{{/if}}
</th>
</tr>
</thead>
<tbody>
{{#each filteredUsers key="id" as |user|}}
<tr>
<td class="{{unless user.active 'inactive-user'}} {{if user.admin 'admin-user'}}">
<div class="d-inline-block align-top">
{{#if user.me}}
<i class="material-icons color-gray">check_box_outline_blank</i>
{{else if user.selected}}
<i class="material-icons checkbox" {{action 'toggleSelect' user}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleSelect' user}}>check_box_outline_blank</i>
{{/if}}
</div>
<div class="d-inline-block">
<div class="name d-inline-block">{{ user.fullname }}</div>
<div class="email">{{ user.email }}</div>
</div>
</td>
<td class="no-width text-center">
{{#if user.editor}}
<i class="material-icons checkbox" {{action 'toggleEditor' user.id}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleEditor' user.id}}>check_box_outline_blank</i>
{{/if}}
</td>
<td class="no-width text-center">
{{#if user.viewUsers}}
<i class="material-icons checkbox" {{action 'toggleUsers' user.id}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleUsers' user.id}}>check_box_outline_blank</i>
{{/if}}
</td>
<td class="no-width text-center">
{{#if user.me}}
<i class="material-icons color-gray">check_box</i>
{{else if user.admin}}
<i class="material-icons checkbox" {{action 'toggleAdmin' user.id}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleAdmin' user.id}}>check_box_outline_blank</i>
{{/if}}
</td>
<td class="no-width text-center">
{{#if user.me}}
<i class="material-icons color-gray">check_box</i>
{{else if user.active}}
<i class="material-icons checkbox" {{action 'toggleActive' user.id}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleActive' user.id}}>check_box_outline_blank</i>
{{/if}}
</td>
<td class="no-width text-center">
{{#if user.me}}
<div class="edit-button-{{user.id}} button-icon-gray" title="Edit" {{action "onShowEdit" user.id}}>
<i class="material-icons">edit</i>
</div>
{{else}}
<div class="edit-button-{{user.id}} button-icon-gray" title="Edit" {{action "onShowEdit" user.id}}>
<i class="material-icons">edit</i>
</div>
<div class="button-icon-gap"></div>
<div class="delete-button-{{user.id}} button-icon-danger" title="Delete" {{action "onShowDelete" user.id}}>
<i class="material-icons">delete</i>
</div>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
{{/if}}
<div id="edit-user-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">User {{editUser.firstname}} {{editUser.lastname}}</div>
<div class="modal-body">
<form>
<div class="form-group">
<label for="edit-firstname">Firstname</label>
{{input id="edit-firstname" class="form-control" type="text" value=editUser.firstname}}
</div>
<div class="form-group">
<label for="edit-lastname">Lastname</label>
{{input id="edit-lastname" type="text" class="form-control" value=editUser.lastname}}
</div>
<div class="form-group">
<label for="edit-email">Email</label>
{{input id="edit-email" type="text" class="form-control" value=editUser.email}}
</div>
{{#if isAuthProviderDocumize}}
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="edit-password">Password</label>
<div class="tip">Optional new password</div>
{{input id="edit-password" type="password" class="form-control" value=password.password}}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="edit-confirmPassword">Confirm Password</label>
<div class="tip">Confirm new password</div>
{{input id="edit-confirmPassword" type="password" class="form-control" value=password.confirmation}}
</div>
</div>
</div>
{{/if}}
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" onclick={{action 'onUpdate'}}>Save</button>
</div>
</div>
</div>
</div>
{{#ui/ui-dialog title="Delete User" confirmCaption="Delete" buttonType="btn-danger" show=showDeleteDialog onAction=(action 'onDelete')}}
<p>Are you sure you want to delete {{deleteUser.fullname}}?</p>
{{/ui/ui-dialog}}

View file

@ -0,0 +1,158 @@
<div class="row">
<div class="col">
<div class="view-customize">
<h1 class="admin-heading">Groups</h1>
<h2 class="sub-heading">Create groups for easier user management &mdash; assign users to groups</h2>
<div class="btn btn-success mt-3 mb-3" {{action 'onOpenGroupModal'}}>Add group</div>
<div id="add-group-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">Add Group</div>
<div class="modal-body">
<form onsubmit={{action 'onAddGroup'}}>
<div class="form-group">
<label for="new-group-name">Name</label>
{{focus-input id="new-group-name" type="text" class="form-control mousetrap" placeholder="Enter group name" value=newGroup.name}}
<small class="form-text text-muted">e.g. Managers, Developers, Acme Team</small>
</div>
<div class="form-group">
<label for="new-group-desc">Description (optional)</label>
{{textarea id="new-group-desc" value=newGroup.purpose class="form-control" rows="3"}}
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" onclick={{action 'onAddGroup'}}>Add</button>
</div>
</div>
</div>
</div>
<div class="groups-list">
{{#each groups as |group|}}
<div class="row group">
<div class="col-8">
<div class="name">
{{group.name}}
{{#if group.purpose}}
<div class="purpose">&nbsp;&nbsp;&mdash;&nbsp;{{group.purpose}}</div>
{{/if}}
</div>
</div>
<div class="col-4 buttons text-right">
<button class="btn btn-sm btn-secondary" {{action 'onShowMembersModal' group.id}}>{{group.members}} members</button>
<div class="button-icon-gap" />
<div class="button-icon-gray align-middle" data-toggle="tooltip" data-placement="top" title="Rename" {{action 'onShowEditModal' group.id}}>
<i class="material-icons">edit</i>
</div>
<div class="button-icon-gap" />
<div class="button-icon-danger align-middle" data-toggle="tooltip" data-placement="top" title="Delete" {{action 'onShowDeleteModal' group.id}}>
<i class="material-icons">delete</i>
</div>
</div>
</div>
{{else}}
<div class="margin-top-30"><i>No groups</i></div>
{{/each}}
</div>
<div id="delete-group-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">Delete Group</div>
<div class="modal-body">
<form onsubmit={{action 'onDeleteGroup'}}>
<p>Are you sure you want to delete this group?</p>
<div class="form-group">
<label for="delete-group-name">Please type group name to confirm</label>
{{input id="delete-group-name" type="text" class="form-control mousetrap" placeholder="Group name" value=deleteGroup.name}}
<small class="form-text text-muted">This will remove group membership information and associated permissions!</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-danger" onclick={{action 'onDeleteGroup'}}>Delete</button>
</div>
</div>
</div>
</div>
<div id="edit-group-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">Edit Group</div>
<div class="modal-body">
<form onsubmit={{action 'onEditGroup'}}>
<div class="form-group">
<label for="edit-group-name">Name</label>
{{input id="edit-group-name" type="text" class="form-control mousetrap" placeholder="Enter group name" value=editGroup.name}}
<small class="form-text text-muted">e.g. Managers, Developers, Acme Team</small>
</div>
<div class="form-group">
<label for="edit-group-desc">Description (optional)</label>
{{textarea id="edit-group-desc" value=editGroup.purpose class="form-control" rows="3"}}
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" onclick={{action 'onEditGroup'}}>Save</button>
</div>
</div>
</div>
</div>
<div id="group-members-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">{{membersGroup.name}} ({{members.length}})</div>
<div class="modal-body">
<div class="form-group">
<label for="group-members-search">Search for group members, non-members</label>
{{input id="group-members-search" type="text" class="form-control mousetrap" placeholder="Search members and users..." value=searchText key-up=(action 'onSearch')}}
<small class="form-text text-muted">search firstname, lastname, email</small>
</div>
<div class="view-customize">
<div class="group-users-members my-5">
{{#if showMembers}}
{{#each members as |member|}}
<div class="row item">
<div class="col-10 fullname">{{member.fullname}}</div>
<div class="col-2 text-right">
<button class="btn btn-danger" {{action 'onLeaveGroup' member.userId}}>Remove</button>
</div>
</div>
{{/each}}
{{/if}}
{{#if showUsers}}
{{#each users as |user|}}
<div class="row item">
<div class="col-10 fullname">{{user.firstname}} {{user.lastname}}</div>
<div class="col-2 text-right">
{{#if user.isMember}}
<button class="btn btn-danger" {{action 'onLeaveGroup' user.id}}>Remove</button>
{{else}}
<button class="btn btn-success" {{action 'onJoinGroup' user.id}}>Add</button>
{{/if}}
</div>
</div>
{{/each}}
{{/if}}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,212 @@
<div class="view-customize my-5">
<div class="my-2">
<span class="font-weight-bold">Spaces</span>
<span class="text-muted">&nbsp;&nbsp;&mdash;&nbsp;can add spaces, both personal and shared with others</span>
</div>
<div class="my-2">
<span class="font-weight-bold">Visible</span>
<span class="text-muted">&nbsp;&nbsp;&mdash;&nbsp;can see names of users and groups, can disable for external users like customers/partners</span>
</div>
<div class="my-2">
<span class="font-weight-bold">Admin</span>
<span class="text-muted">&nbsp;&nbsp;&mdash;&nbsp;can manage all aspects of Documize, like this screen</span>
</div>
<div class="mt-2 mb-4">
<span class="font-weight-bold">Active</span>
<span class="text-muted">&nbsp;&nbsp;&mdash;&nbsp;can login and use Documize</span>
</div>
<div class="form-group mt-5 mb-3">
{{focus-input type="text" class="form-control" placeholder="filter users" value=filter}}
<small class="form-text text-muted">search firstname, lastname, email</small>
</div>
<table class="table table-responsive table-borderless user-table">
<thead>
<tr>
<th class="text-muted">
{{#if hasSelectedUsers}}
<button id="bulk-delete-users" type="button" class="btn btn-danger" data-toggle="modal" data-target="#admin-user-delete-modal" data-backdrop="static">Delete selected users</button>
{{/if}}
</th>
<th class="no-width">Spaces</th>
<th class="no-width">Visible</th>
<th class="no-width">Admin</th>
<th class="no-width">Active</th>
<th class="no-width">
</th>
</tr>
</thead>
<tbody>
{{#each users key="id" as |user|}}
<tr>
<td class="{{unless user.active 'inactive-user'}} {{if user.admin 'admin-user'}}">
<div class="d-inline-block align-top">
{{#if user.me}}
<i class="material-icons color-gray">check_box_outline_blank</i>
{{else if user.selected}}
<i class="material-icons checkbox" {{action 'toggleSelect' user}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleSelect' user}}>check_box_outline_blank</i>
{{/if}}
</div>
<div class="d-inline-block">
<div class="name" {{action "onShowEdit" user.id}}>{{user.fullname}}<div class="email">&nbsp;&nbsp;({{user.email}})</div></div>
<div class="groups" {{action "onShowGroupsModal" user.id}}>
{{#each user.groups as |group|}}
<span class="group">
{{group.name}}{{#if (not-eq group user.groups.lastObject)}}, {{/if}}
</span>
{{else}}
<span class="group">&lt;no groups&gt;</span>
{{/each}}
</div>
</div>
</td>
<td class="no-width text-center">
{{#if user.editor}}
<i class="material-icons checkbox" {{action 'toggleEditor' user.id}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleEditor' user.id}}>check_box_outline_blank</i>
{{/if}}
</td>
<td class="no-width text-center">
{{#if user.viewUsers}}
<i class="material-icons checkbox" {{action 'toggleUsers' user.id}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleUsers' user.id}}>check_box_outline_blank</i>
{{/if}}
</td>
<td class="no-width text-center">
{{#if user.me}}
<i class="material-icons color-gray">check_box</i>
{{else if user.admin}}
<i class="material-icons checkbox" {{action 'toggleAdmin' user.id}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleAdmin' user.id}}>check_box_outline_blank</i>
{{/if}}
</td>
<td class="no-width text-center">
{{#if user.me}}
<i class="material-icons color-gray">check_box</i>
{{else if user.active}}
<i class="material-icons checkbox" {{action 'toggleActive' user.id}}>check_box</i>
{{else}}
<i class="material-icons checkbox" {{action 'toggleActive' user.id}}>check_box_outline_blank</i>
{{/if}}
</td>
<td class="no-width text-center">
<div class="user-button-{{user.id}} button-icon-gray button-icon-small" title="Edit" {{action "onShowEdit" user.id}}>
<i class="material-icons">mode_edit</i>
</div>
{{#unless user.me}}
<div class="button-icon-gap" />
<div class="delete-button-{{user.id}} button-icon-red button-icon-small" title="Delete" {{action "onShowDelete" user.id}}>
<i class="material-icons">delete</i>
</div>
{{/unless}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
<div id="edit-user-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">User {{editUser.firstname}} {{editUser.lastname}}</div>
<div class="modal-body">
<form>
<div class="form-group">
<label for="edit-firstname">Firstname</label>
{{input id="edit-firstname" class="form-control" type="text" value=editUser.firstname}}
</div>
<div class="form-group">
<label for="edit-lastname">Lastname</label>
{{input id="edit-lastname" type="text" class="form-control" value=editUser.lastname}}
</div>
<div class="form-group">
<label for="edit-email">Email</label>
{{input id="edit-email" type="text" class="form-control" value=editUser.email}}
</div>
{{#if isAuthProviderDocumize}}
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="edit-password">Password</label>
{{input id="edit-password" type="password" class="form-control" value=password.password}}
<small class="form-text text-muted">Optional new password</small>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="edit-confirmPassword">Confirm Password</label>
{{input id="edit-confirmPassword" type="password" class="form-control" value=password.confirmation}}
<small class="form-text text-muted">Confirm new password</small>
</div>
</div>
</div>
{{/if}}
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" onclick={{action 'onUpdate'}}>Save</button>
</div>
</div>
</div>
</div>
{{#ui/ui-dialog title="Delete User" confirmCaption="Delete" buttonType="btn-danger" show=showDeleteDialog onAction=(action 'onDelete')}}
<p>Are you sure you want to delete {{deleteUser.fullname}}?</p>
{{/ui/ui-dialog}}
<div id="admin-user-delete-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">Delete Users</div>
<div class="modal-body">
<p>Are you sure you want to delete selected users?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" onclick={{action 'onBulkDelete'}}>Delete</button>
</div>
</div>
</div>
</div>
<div id="group-member-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">{{selectedUser.fullname}}</div>
<div class="modal-body">
<div class="view-customize">
<div class="group-membership my-5">
{{#each groups as |group|}}
<div class="row item">
<div class="col-10 group-name">{{group.name}}
{{#if group.purpose}}
<span class="text-muted group-purpose">&nbsp;&nbsp;&mdash;&nbsp;{{group.purpose}}</span>
{{/if}}
</div>
<div class="col-2 text-right">
{{#if group.isMember}}
<button class="btn btn-danger" {{action 'onLeaveGroup' group.id}}>Leave</button>
{{else}}
<button class="btn btn-success" {{action 'onJoinGroup' group.id}}>Join</button>
{{/if}}
</div>
</div>
{{/each}}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>

View file

@ -1,17 +0,0 @@
{{#if isAuthProviderDocumize}}
<div class="view-customize mt-5 mb-5">
<h3>Add User</h3>
<form class="form-inline">
<label class="sr-only" for="newUserFirstname">Firstname</label>
{{focus-input id="newUserFirstname" type="text" value=newUser.firstname placeholder="Firstname" class=(if hasFirstnameEmptyError 'form-control mb-2 mr-sm-4 mb-sm-0 is-invalid' 'form-control mb-2 mr-sm-4 mb-sm-0')}}
<label class="sr-only" for="newUserLastname">Lastname</label>
{{input id="newUserLastname" type="text" value=newUser.lastname placeholder="Lastname" class=(if hasLastnameEmptyError 'form-control mb-2 mr-sm-4 mb-sm-0 is-invalid' 'form-control mb-2 mr-sm-4 mb-sm-0')}}
<label class="sr-only" for="newEmail">Email</label>
{{input id="newEmail" type="email" value=newUser.email placeholder="Email" class=(if hasEmailEmptyError 'form-control mb-2 mr-sm-4 mb-sm-0 is-invalid' 'form-control mb-2 mr-sm-4 mb-sm-0')}}
<button type="submit" class="btn btn-success" {{action 'add'}}>Add</button>
</form>
</div>
{{/if}}

View file

@ -6,7 +6,7 @@
<p class="sub-title">Sub-divide spaces into categories which can contain documents with restricted access.</p>
<form class="form-inline" onsubmit={{action 'onAdd'}}>
<div class="form-group mr-3">
{{input id="new-category-name" type='text' class="form-control mousetrap" placeholder="Category name" value=newCategory}}
{{focus-input id="new-category-name" type='text' class="form-control mousetrap" placeholder="Category name" value=newCategory}}
</div>
<button type="button" class="btn btn-success" onclick={{action 'onAdd'}}>Add</button>
</form>
@ -27,7 +27,7 @@
{{else}}
<div class="category col-8">
<div class="name">{{cat.category}}</div>
<div class="info">{{cat.documents}} {{if (eq cat.documents 1) 'document' 'documents' }}, {{cat.users}} {{if (eq cat.users 1) 'person' 'people' }}</div>
<div class="info">{{cat.documents}} {{if (eq cat.documents 1) 'document' 'documents' }} &middot; {{cat.users}} users/groups</div>
</div>
{{/if}}
<div class="col-4 buttons text-right">
@ -72,8 +72,40 @@
</div>
</div>
{{#ui/ui-dialog title="Set Cateogory Access" confirmCaption="Save" buttonType="btn-success" show=showCategoryAccess onAction=(action 'onGrantAccess')}}
{{#ui/ui-dialog title="Set Category Access" confirmCaption="Save" buttonType="btn-success" show=showCategoryAccess onAction=(action 'onGrantAccess')}}
<p>Select who can view documents within category</p>
{{ui/ui-list-picker items=categoryUsers nameField='fullname' singleSelect=false}}
<div class="widget-list-picker">
<ul class="options">
{{#each categoryPermissions as |permission|}}
<li class="option {{if permission.selected 'selected'}}" {{action 'onToggle' permission}}>
<div class="text text-truncate">
{{#if (eq permission.who "role")}}
<span class="button-icon-gray button-icon-small align-middle">
<i class="material-icons">people</i>
</span>
{{else}}
{{#if (eq permission.whoId constants.EveryoneUserId)}}
<span class="button-icon-gray button-icon-small align-middle">
<i class="material-icons">language</i>
</span>
{{else}}
<span class="button-icon-gray button-icon-small align-middle">
<i class="material-icons">person</i>
</span>
{{/if}}
{{/if}}
&nbsp;{{permission.name}}
{{#if (eq permission.whoId session.user.id)}}
<small class="form-text text-muted d-inline-block">(you)</small>
{{/if}}
</div>
{{#if permission.selected}}
<i class="material-icons">check</i>
{{/if}}
</li>
{{/each}}
</ul>
</div>
{{/ui/ui-dialog}}
</div>

View file

@ -3,9 +3,9 @@
<div class="modal-content">
<div class="modal-header">Space Permissions</div>
<div class="modal-body" style="overflow-x: auto;">
<div class="space-admin table-responsive">
<table class="table table-hover permission-table">
<table class="table table-hover permission-table mb-3">
<thead>
<tr>
<th></th>
@ -27,45 +27,89 @@
</tr>
</thead>
<tbody>
{{#each permissions as |permission|}}
{{#each spacePermissions as |permission|}}
<tr>
<td>{{permission.fullname}} {{if (eq permission.userId session.user.id) '(you)'}}</td>
<td>
{{input type="checkbox" id=(concat 'space-role-view-' permission.userId) checked=permission.spaceView}}
{{#if (eq permission.who "role")}}
<span class="button-icon-blue button-icon-small align-middle">
<i class="material-icons">people</i>
</span>
<span class="color-blue">&nbsp;{{permission.name}}
<small class="form-text text-muted d-inline-block">({{permission.members}})</small>
</span>
{{else}}
{{#if (eq permission.whoId constants.EveryoneUserId)}}
<span class="button-icon-green button-icon-small align-middle">
<i class="material-icons">language</i>
</span>
<span class="color-green">&nbsp;{{permission.name}}</span>
{{else}}
<span class="button-icon-gray button-icon-small align-middle">
<i class="material-icons">person</i>
</span>
<span class="">&nbsp;{{permission.name}}
{{#if (eq permission.whoId session.user.id)}}
<small class="form-text text-muted d-inline-block">(you)</small>
{{/if}}
</span>
{{/if}}
{{/if}}
</td>
<td>
{{input type="checkbox" id=(concat 'space-role-manage-' permission.userId) checked=permission.spaceManage}}
{{input type="checkbox" id=(concat 'space-role-view-' permission.whoId) checked=permission.spaceView}}
</td>
<td>
{{input type="checkbox" id=(concat 'space-role-owner-' permission.userId) checked=permission.spaceOwner}}
{{input type="checkbox" id=(concat 'space-role-manage-' permission.whoId) checked=permission.spaceManage}}
</td>
<td>
{{input type="checkbox" id=(concat 'doc-role-add-' permission.userId) checked=permission.documentAdd}}
{{input type="checkbox" id=(concat 'space-role-owner-' permission.whoId) checked=permission.spaceOwner}}
</td>
<td>
{{input type="checkbox" id=(concat 'doc-role-edit-' permission.userId) checked=permission.documentEdit}}
{{input type="checkbox" id=(concat 'doc-role-add-' permission.whoId) checked=permission.documentAdd}}
</td>
<td>
{{input type="checkbox" id=(concat 'doc-role-delete-' permission.userId) checked=permission.documentDelete}}
{{input type="checkbox" id=(concat 'doc-role-edit-' permission.whoId) checked=permission.documentEdit}}
</td>
<td>
{{input type="checkbox" id=(concat 'doc-role-move-' permission.userId) checked=permission.documentMove}}
{{input type="checkbox" id=(concat 'doc-role-delete-' permission.whoId) checked=permission.documentDelete}}
</td>
<td>
{{input type="checkbox" id=(concat 'doc-role-copy-' permission.userId) checked=permission.documentCopy}}
{{input type="checkbox" id=(concat 'doc-role-move-' permission.whoId) checked=permission.documentMove}}
</td>
<td>
{{input type="checkbox" id=(concat 'doc-role-template-' permission.userId) checked=permission.documentTemplate}}
{{input type="checkbox" id=(concat 'doc-role-copy-' permission.whoId) checked=permission.documentCopy}}
</td>
<td>
{{input type="checkbox" id=(concat 'doc-role-approve-' permission.userId) checked=permission.documentApprove}}
{{input type="checkbox" id=(concat 'doc-role-template-' permission.whoId) checked=permission.documentTemplate}}
</td>
<td>
{{input type="checkbox" id=(concat 'doc-role-approve-' permission.whoId) checked=permission.documentApprove}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-6">
<div class="form-group">
{{focus-input id="user-search" type="text" class="form-control mousetrap" placeholder="Search users..." value=searchText key-up=(action 'onSearch')}}
<small class="form-text text-muted">firstname, lastname, email</small>
</div>
{{#each filteredUsers as |user|}}
<div class="row my-3">
<div class="col-10">{{user.fullname}}</div>
<div class="col-2 text-right">
<button class="btn btn-primary" {{action 'onAdd' user}}>Add</button>
</div>
</div>
{{/each}}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">Cancel</button>

View file

@ -9,8 +9,8 @@
</div>
<div class="form-group">
<label>Username</label>
<div class="tip">Gemini Username</div>
{{input id="gemini-username" type="text" value=config.username class="form-control"}}
<small class="form-text text-muted">Gemini Username</small>
</div>
<div class="form-group">
<label for="gemini-apikey">API Key</label>

View file

@ -1,6 +1,6 @@
{
"name": "documize",
"version": "1.57.3",
"version": "1.58.0",
"description": "The Document IDE",
"private": true,
"repository": "",

View file

@ -1,16 +1,16 @@
{
"community":
{
"version": "1.57.3",
"version": "1.58.0",
"major": 1,
"minor": 57,
"patch": 3
"minor": 58,
"patch": 0
},
"enterprise":
{
"version": "1.59.3",
"version": "1.60.0",
"major": 1,
"minor": 59,
"patch": 3
"minor": 60,
"patch": 0
}
}

View file

@ -74,4 +74,9 @@ const (
EventTypeCategoryUpdate EventType = "updated-category"
EventTypeCategoryLink EventType = "linked-category"
EventTypeCategoryUnlink EventType = "unlinked-category"
EventTypeGroupAdd EventType = "added-group"
EventTypeGroupDelete EventType = "removed-group"
EventTypeGroupUpdate EventType = "updated-group"
EventTypeGroupJoin EventType = "joined-group"
EventTypeGroupLeave EventType = "left-group"
)

Some files were not shown because too many files have changed in this diff Show more